diff --git a/.all-contributorsrc b/.all-contributorsrc index 598b7bca4..b037cabba 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -69,6 +69,42 @@ "contributions": [ "code" ] + }, + { + "login": "cschuijt", + "name": "Casper Schuijt", + "avatar_url": "https://avatars.githubusercontent.com/u/5460015?v=4", + "profile": "http://cschuijt.nl", + "contributions": [ + "bug" + ] + }, + { + "login": "ANasouf", + "name": "ANasouf", + "avatar_url": "https://avatars.githubusercontent.com/u/19536487?v=4", + "profile": "https://github.com/ANasouf", + "contributions": [ + "code" + ] + }, + { + "login": "xprnio", + "name": "Ragnar Laud", + "avatar_url": "https://avatars.githubusercontent.com/u/3042904?v=4", + "profile": "https://ragnarlaud.dev", + "contributions": [ + "bug" + ] + }, + { + "login": "asenawritescode", + "name": "Asena", + "avatar_url": "https://avatars.githubusercontent.com/u/67445192?v=4", + "profile": "https://github.com/asenawritescode", + "contributions": [ + "bug" + ] } ], "contributorsPerLine": 7, diff --git a/.env.example b/.env.example index 7a86ab280..72882dd40 100644 --- a/.env.example +++ b/.env.example @@ -56,5 +56,42 @@ GOTENBERG_URL=http://gotenberg:3000 GOTENBERG_DOCS_URL=http://server:3000/public/ # Gotenberg API - (development) -# GOTENBERG_URL=http://gotenberg:3000 -# GOTENBERG_DOCS_URL=http://server:3000/public/ \ No newline at end of file +# GOTENBERG_URL=http://localhost:9000 +# GOTENBERG_DOCS_URL=http://host.docker.internal:3000/public/ + +# Exchange Rate Service +EXCHANGE_RATE_SERVICE=open-exchange-rate + +# Open Exchange Rate +OPEN_EXCHANGE_RATE_APP_ID= + +# The Plaid environment to use ('sandbox' or 'development'). +# https://plaid.com/docs/#api-host +PLAID_ENV=sandbox + +# Your Plaid keys, which can be found in the Plaid Dashboard. +# https://dashboard.plaid.com/account/keys +PLAID_CLIENT_ID= +PLAID_SECRET_DEVELOPMENT= +PLAID_SECRET_SANDBOX= + +PLAID_LINK_WEBHOOK= + +# (Optional) Redirect URI settings section +# Only required for OAuth redirect URI testing (not common on desktop): +# Sandbox Mode: +# Set the PLAID_SANDBOX_REDIRECT_URI below to 'http://localhost:3001/oauth-link'. +# The OAuth redirect flow requires an endpoint on the developer's website +# that the bank website should redirect to. You will also need to configure +# this redirect URI for your client ID through the Plaid developer dashboard +# at https://dashboard.plaid.com/team/api. +# Development mode: +# When running in development mode, you must use an https:// url. +# You will need to configure this https:// redirect URI in the Plaid developer dashboard. +# Instructions to create a self-signed certificate for localhost can be found at +# https://github.com/plaid/pattern/blob/master/README.md#testing-oauth. +# If your system is not set up to run localhost with https://, you will be unable to test +# the OAuth in development and should leave the PLAID_DEVELOPMENT_REDIRECT_URI blank. + +PLAID_SANDBOX_REDIRECT_URI= +PLAID_DEVELOPMENT_REDIRECT_URI= diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b4f3f877..ae75e5f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,77 @@ All notable changes to Bigcapital server-side will be in this file. -# [0.10.1] - 25-09-2023 +## [0.14.0] - 30-01-2024 + +* feat: purchases by items exporting by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/327 +* fix: expense amounts should not be rounded by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/339 +* feat: get latest exchange rate from third party services by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/340 +* fix(webapp): inconsistency in currency of universal search items by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/335 +* hotfix: editing sales and expense transactions don't reflect GL entries by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/342 + +## [0.13.3] - 22-01-2024 + +* hotfix(server): Unhandled thrown errors of services by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/329 + +## [0.13.2] - 21-01-2024 + +* feat: show customer / vendor balance. by @asenawritescode in https://github.com/bigcapitalhq/bigcapital/pull/311 +* feat: inventory valuation csv and xlsx export by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/308 +* feat: sales by items export csv & xlsx by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/310 +* fix(server): the invoice and payment receipt printing by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/315 +* fix: get cashflow transaction broken cause transaction type by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/318 +* fix: `AccountActivateAlert` import by @xprnio in https://github.com/bigcapitalhq/bigcapital/pull/322 + +## [0.13.1] - 15-01-2024 + +* feat(webapp): add approve/reject to action bar of estimate details dr… by @ANasouf in https://github.com/bigcapitalhq/bigcapital/pull/304 +* docs: add ANasouf as a contributor for code by @allcontributors in https://github.com/bigcapitalhq/bigcapital/pull/305 +* feat: Export general ledger & Journal to CSV and XLSX by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/303 +* feat: Auto re-calculate the items rate once changing the invoice exchange rate. by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/270 + +## [0.13.0] - 31-12-2023 + +* feat: Send an invoice mail the customer email by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/292 +* fix: Allow non-numeric postal codes by @cschuijt in https://github.com/bigcapitalhq/bigcapital/pull/294 +* docs: add cschuijt as a contributor for bug by @allcontributors in https://github.com/bigcapitalhq/bigcapital/pull/295 + +## [0.12.1] - 17-11-2023 + +* feat: Add default customer message and terms conditions to the transactions by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/291 +* fix: The currency code of transaction tax rate entry by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/293 + +## [0.12.0] - 04-11-2023 + +* feat: Export reports via CSV and XLSX by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/286 +* fix: Axios upgrade by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/288 +* fix(server): Allow decimal amount in sale/purchase transactions. by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/289 +* feat: Optimize invoice documents printing by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/280 +* chore(deps): bump axios from 0.20.0 to 1.6.0 in /packages/server by @dependabot in https://github.com/bigcapitalhq/bigcapital/pull/284 +* chore(deps): bump axios from 0.20.0 to 1.6.0 by @dependabot in https://github.com/bigcapitalhq/bigcapital/pull/283 + +## [0.11.0] - 28-10-2023 + +* feat: Migrate to pnpm by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/253 +* feat: Integrate tax rates to bills by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/260 +* feat: Assign default sell/purchase tax rates to items by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/261 +* chore(deps-dev): bump @babel/traverse from 7.23.0 to 7.23.2 in /packages/server by @dependabot in https://github.com/bigcapitalhq/bigcapital/pull/272 +* feat: Improve financial statements rows color by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/276 +* fix: Trial balance sheet adjusted balance by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/273 +* feat: Adds tax numbers to organization and customers by @kochie in https://github.com/bigcapitalhq/bigcapital/pull/269 +* docs: Add kochie as a contributor for code by @allcontributors in https://github.com/bigcapitalhq/bigcapital/pull/277 +* feat: Computed Net Income under Equity in Balance Sheet report. by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/271 +* fix: Change Dockerfile files with new pnpm by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/278 + +## [0.10.2] - 02-10-2023 + +fix(webapp): Disable tax rates from item entries editor table services do not support tax rates (https://github.com/bigcapitalhq/bigcapital/commit/69afa07e3ba45495a4cab3490c15f2b0c40c4790) by @abouolia +fix(server): Add missing method in ItemEntry model (https://github.com/bigcapitalhq/bigcapital/commit/07628ddc37f46c98959ced0323f28752e0a98944) by @abouolia + +## [0.10.1] - 25-09-2023 * Fix: Running tenants migration on Docker migration container by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/242 -# [0.10.0] - 24-09-2023 +## [0.10.0] - 24-09-2023 * Added: Tax rates service by @abouolia @elforjani13 in https://github.com/bigcapitalhq/bigcapital/pull/204 * Added: Sales Tax Liability Summary report by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/204 @@ -16,7 +82,7 @@ All notable changes to Bigcapital server-side will be in this file. * chore(deps): bump word-wrap from 1.2.3 to 1.2.4 in /packages/webapp by @dependabot in https://github.com/bigcapitalhq/bigcapital/pull/199 * chore(deps): bump mongoose from 5.13.15 to 5.13.20 by @dependabot in https://github.com/bigcapitalhq/bigcapital/pull/197 -# [0.9.12] - 29-08-2023 +## [0.9.12] - 29-08-2023 * Refactor: split the services to multiple service classes. (by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/202) * Fix: create quick customer/vendor by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/206 @@ -30,18 +96,18 @@ All notable changes to Bigcapital server-side will be in this file. * Fix: filter by customers, vendors and items in reports do not work by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/224 https://github.com/bigcapitalhq/bigcapital/pull/225 -# [0.9.11] - 23-07-2023 +## [0.9.11] - 23-07-2023 * added: Restart policy to docker compose files. by @suhaibaffan in https://github.com/bigcapitalhq/bigcapital/pull/198 * fix: Expose and expand the rate limit to the env variables by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/195 -# [0.9.10] - 18-07-2023 +## [0.9.10] - 18-07-2023 * feat(e2e): E2E onboarding process by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/176 * fix(webapp): Show loading message of cost computing job on financial reports by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/196 * fix(webapp): Change the currency code of sales and purchases transactions with foreign contacts. -# [0.9.9] - 28-06-2023 +## [0.9.9] - 28-06-2023 * refactor: Customer and vendor select component by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/171 * chore: Move auto-increment components in separate files by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/170 @@ -53,7 +119,7 @@ https://github.com/bigcapitalhq/bigcapital/pull/225 * fix: No currency in amount field on money in/out dialogs by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/179 * fix: No default branch for customer/vendor opening balance branch by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/182 -# [0.9.8] - 19-06-2023 +## [0.9.8] - 19-06-2023 `bigcapitalhq/webapp` diff --git a/README.md b/README.md index 5476c3201..8f9f0600a 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,12 @@ To get started locally, we have a [guide to help you](https://github.com/bigcapi [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/new/#https://github.com/bigcapitalhq/bigcapital) +## Headless Accounting + +You can integrate Bigcapital API with your system to organize your transactions in double-entry system to get the best financial reports. + +[![Run in Postman](https://run.pstmn.io/button.svg)](https://www.postman.com/bigcapital/workspace/bigcapital-api) + # Resources - [Documentation](https://docs.bigcapital.ly/) - Learn how to use. @@ -107,6 +113,12 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Kalliopi Pliogka
Kalliopi Pliogka

🐛 Robert Koch
Robert Koch

💻 + + Casper Schuijt
Casper Schuijt

🐛 + ANasouf
ANasouf

💻 + Ragnar Laud
Ragnar Laud

🐛 + Asena
Asena

🐛 + diff --git a/packages/server/package.json b/packages/server/package.json index 39bc8a217..ef260cd19 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -94,12 +94,14 @@ "pluralize": "^8.0.0", "pug": "^3.0.2", "puppeteer": "^10.2.0", + "plaid": "^10.3.0", "qim": "0.0.52", "ramda": "^0.27.1", "rate-limiter-flexible": "^2.1.14", "reflect-metadata": "^0.1.13", "rtl-detect": "^1.0.4", "source-map-loader": "^4.0.1", + "socket.io": "^4.7.4", "tmp-promise": "^3.0.3", "ts-transformer-keys": "^0.4.2", "tsyringe": "^4.3.0", diff --git a/packages/server/resources/scss/modules/financial-sheet.scss b/packages/server/resources/scss/modules/financial-sheet.scss new file mode 100644 index 000000000..bceb51dc0 --- /dev/null +++ b/packages/server/resources/scss/modules/financial-sheet.scss @@ -0,0 +1,57 @@ +@import "../base.scss"; + +html, +body { + font-size: 14px; +} +body{ + font-weight: 400; + letter-spacing: 0; + line-height: 1.28581; + text-transform: none; + color: #000; + font-family: Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue, Icons16, sans-serif; +} +.sheet{ + padding: 20px; +} +.sheet__company-name{ + margin: 0; + font-size: 1.4rem; +} +.sheet__sheet-type { + margin: 0 +} +.sheet__sheet-date { + margin-top: 0.35rem; +} + +.sheet__header { + text-align: center; + margin-bottom: 1rem; +} + +.sheet__table { + border-top: 1px solid #000; + table-layout: fixed; + border-spacing: 0; + text-align: left; + font-size: inherit; + width: 100%; +} + +.sheet__table thead th { + color: #000; + border-bottom: 1px solid #000000; + padding: 0.5rem; +} + +.sheet__table tbody td { + border-bottom: 0; + padding-top: 0.28rem; + padding-bottom: 0.28rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + color: #252A31; + border-bottom: 1px solid transparent; +} \ No newline at end of file diff --git a/packages/server/resources/views/modules/financial-sheet.pug b/packages/server/resources/views/modules/financial-sheet.pug new file mode 100644 index 000000000..9292c61de --- /dev/null +++ b/packages/server/resources/views/modules/financial-sheet.pug @@ -0,0 +1,25 @@ +block head + style + include ../../css/modules/financial-sheet.css + +style. + !{customCSS} + +block content + .sheet + .sheet__header + .sheet__company-name=organizationName + .sheet__sheet-type=sheetName + .sheet__sheet-date=sheetDate + + table.sheet__table + thead + tr + each column in table.columns + th(style=column.style class='column--' + column.key)= column.label + tbody + each row in table.rows + tr(class=row.classNames) + each cell in row.cells + td(class='cell--' + cell.key) + span!= cell.value \ No newline at end of file diff --git a/packages/server/scripts/gulpConfig.js b/packages/server/scripts/gulpConfig.js index 51bbc3aff..1caa9af23 100644 --- a/packages/server/scripts/gulpConfig.js +++ b/packages/server/scripts/gulpConfig.js @@ -66,12 +66,10 @@ module.exports = { // sourcemaps: true, // Allow to enable/disable sourcemaps or pass object to configure it. // minify: true, // Allow to enable/disable minify the source. }, - // { - // src: './assets/sass/editor-style.scss', - // dest: './assets/css', - // sourcemaps: true, - // minify: true, - // }, + { + src: `${RESOURCES_PATH}/scss/modules/financial-sheet.scss`, + dest: `${RESOURCES_PATH}/css/modules`, + }, ], // RTL builds. rtl: [ @@ -114,7 +112,7 @@ module.exports = { // SASS Configuration for all builds. sass: { errLogToConsole: true, - // outputStyle: 'compact', + // outputStyle: 'compact', }, // CSS MQ Packer configuration for all builds and style tasks. diff --git a/packages/server/src/api/controllers/Banking/BankingController.ts b/packages/server/src/api/controllers/Banking/BankingController.ts new file mode 100644 index 000000000..27838a285 --- /dev/null +++ b/packages/server/src/api/controllers/Banking/BankingController.ts @@ -0,0 +1,18 @@ +import Container, { Inject, Service } from 'typedi'; +import { Router } from 'express'; +import BaseController from '@/api/controllers/BaseController'; +import { PlaidBankingController } from './PlaidBankingController'; + +@Service() +export class BankingController extends BaseController { + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.use('/plaid', Container.get(PlaidBankingController).router()); + + return router; + } +} diff --git a/packages/server/src/api/controllers/Banking/PlaidBankingController.ts b/packages/server/src/api/controllers/Banking/PlaidBankingController.ts new file mode 100644 index 000000000..079fdf8bb --- /dev/null +++ b/packages/server/src/api/controllers/Banking/PlaidBankingController.ts @@ -0,0 +1,53 @@ +import { Inject, Service } from 'typedi'; +import { Router, Request, Response } from 'express'; +import BaseController from '@/api/controllers/BaseController'; +import { PlaidApplication } from '@/services/Banking/Plaid/PlaidApplication'; + +@Service() +export class PlaidBankingController extends BaseController { + @Inject() + private plaidApp: PlaidApplication; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.post('/link-token', this.linkToken.bind(this)); + router.post('/exchange-token', this.exchangeToken.bind(this)); + + return router; + } + + /** + * Retrieves the Plaid link token. + * @param {Request} req + * @param {response} res + * @returns {Response} + */ + private async linkToken(req: Request, res: Response) { + const { tenantId } = req; + + const linkToken = await this.plaidApp.getLinkToken(tenantId); + + return res.status(200).send(linkToken); + } + + /** + * Exchanges the given public token. + * @param {Request} req + * @param {response} res + * @returns {Response} + */ + public async exchangeToken(req: Request, res: Response) { + const { tenantId } = req; + const { public_token, institution_id } = req.body; + + await this.plaidApp.exchangeToken(tenantId, { + institutionId: institution_id, + publicToken: public_token, + }); + return res.status(200).send({}); + } +} diff --git a/packages/server/src/api/controllers/Cashflow/CashflowController.ts b/packages/server/src/api/controllers/Cashflow/CashflowController.ts index c6dfe5c29..42efa4c48 100644 --- a/packages/server/src/api/controllers/Cashflow/CashflowController.ts +++ b/packages/server/src/api/controllers/Cashflow/CashflowController.ts @@ -13,9 +13,9 @@ export default class CashflowController { router() { const router = Router(); + router.use(Container.get(CommandCashflowTransaction).router()); router.use(Container.get(GetCashflowTransaction).router()); router.use(Container.get(GetCashflowAccounts).router()); - router.use(Container.get(CommandCashflowTransaction).router()); router.use(Container.get(DeleteCashflowTransaction).router()); return router; diff --git a/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts index 0ddb6d74d..5e0a763d9 100644 --- a/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts @@ -3,14 +3,15 @@ import { Router, Request, Response, NextFunction } from 'express'; import { param } from 'express-validator'; import BaseController from '../BaseController'; import { ServiceError } from '@/exceptions'; -import DeleteCashflowTransactionService from '../../../services/Cashflow/DeleteCashflowTransactionService'; import CheckPolicies from '@/api/middleware/CheckPolicies'; + import { AbilitySubject, CashflowAction } from '@/interfaces'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @Service() -export default class DeleteCashflowTransaction extends BaseController { +export default class DeleteCashflowTransactionController extends BaseController { @Inject() - deleteCashflowService: DeleteCashflowTransactionService; + private cashflowApplication: CashflowApplication; /** * Controller router. @@ -44,7 +45,7 @@ export default class DeleteCashflowTransaction extends BaseController { try { const { oldCashflowTransaction } = - await this.deleteCashflowService.deleteCashflowTransaction( + await this.cashflowApplication.deleteTransaction( tenantId, transactionId ); @@ -92,6 +93,19 @@ export default class DeleteCashflowTransaction extends BaseController { ], }); } + if ( + error.errorType === + 'CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED' + ) { + return res.boom.badRequest(null, { + errors: [ + { + type: 'CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED', + code: 4100, + }, + ], + }); + } } next(error); } diff --git a/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts b/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts index 59fdb91ba..559a5f4f2 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts @@ -1,20 +1,16 @@ import { Service, Inject } from 'typedi'; import { Router, Request, Response, NextFunction } from 'express'; -import { param, query } from 'express-validator'; -import GetCashflowAccountsService from '@/services/Cashflow/GetCashflowAccountsService'; +import { query } from 'express-validator'; import BaseController from '../BaseController'; -import GetCashflowTransactionsService from '@/services/Cashflow/GetCashflowTransactionsService'; import { ServiceError } from '@/exceptions'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @Service() export default class GetCashflowAccounts extends BaseController { @Inject() - getCashflowAccountsService: GetCashflowAccountsService; - - @Inject() - getCashflowTransactionsService: GetCashflowTransactionsService; + private cashflowApplication: CashflowApplication; /** * Controller router. @@ -62,10 +58,7 @@ export default class GetCashflowAccounts extends BaseController { try { const cashflowAccounts = - await this.getCashflowAccountsService.getCashflowAccounts( - tenantId, - filter - ); + await this.cashflowApplication.getCashflowAccounts(tenantId, filter); return res.status(200).send({ cashflow_accounts: this.transfromToResponse(cashflowAccounts), diff --git a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts index 7cf8d2d8e..2625a1cb9 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts @@ -2,15 +2,15 @@ import { Service, Inject } from 'typedi'; import { Router, Request, Response, NextFunction } from 'express'; import { param } from 'express-validator'; import BaseController from '../BaseController'; -import GetCashflowTransactionsService from '@/services/Cashflow/GetCashflowTransactionsService'; import { ServiceError } from '@/exceptions'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @Service() export default class GetCashflowAccounts extends BaseController { @Inject() - getCashflowTransactionsService: GetCashflowTransactionsService; + private cashflowApplication: CashflowApplication; /** * Controller router. @@ -43,11 +43,10 @@ export default class GetCashflowAccounts extends BaseController { const { transactionId } = req.params; try { - const cashflowTransaction = - await this.getCashflowTransactionsService.getCashflowTransaction( - tenantId, - transactionId - ); + const cashflowTransaction = await this.cashflowApplication.getTransaction( + tenantId, + transactionId + ); return res.status(200).send({ cashflow_transaction: this.transfromToResponse(cashflowTransaction), diff --git a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts index 91abfcf92..a1af70c15 100644 --- a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts @@ -1,16 +1,16 @@ import { Service, Inject } from 'typedi'; -import { check } from 'express-validator'; +import { ValidationChain, check, param, query } from 'express-validator'; import { Router, Request, Response, NextFunction } from 'express'; import BaseController from '../BaseController'; import { ServiceError } from '@/exceptions'; -import NewCashflowTransactionService from '@/services/Cashflow/NewCashflowTransactionService'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @Service() export default class NewCashflowTransactionController extends BaseController { @Inject() - private newCashflowTranscationService: NewCashflowTransactionService; + private cashflowApplication: CashflowApplication; /** * Router constructor. @@ -18,6 +18,18 @@ export default class NewCashflowTransactionController extends BaseController { public router() { const router = Router(); + router.get( + '/transactions/uncategorized/:id', + this.asyncMiddleware(this.getUncategorizedCashflowTransaction), + this.catchServiceErrors + ); + router.get( + '/transactions/:id/uncategorized', + this.getUncategorizedTransactionsValidationSchema, + this.validationResult, + this.asyncMiddleware(this.getUncategorizedCashflowTransactions), + this.catchServiceErrors + ); router.post( '/transactions', CheckPolicies(CashflowAction.Create, AbilitySubject.Cashflow), @@ -26,13 +38,72 @@ export default class NewCashflowTransactionController extends BaseController { this.asyncMiddleware(this.newCashflowTransaction), this.catchServiceErrors ); + router.post( + '/transactions/:id/uncategorize', + this.revertCategorizedCashflowTransaction, + this.catchServiceErrors + ); + router.post( + '/transactions/:id/categorize', + this.categorizeCashflowTransactionValidationSchema, + this.validationResult, + this.categorizeCashflowTransaction, + this.catchServiceErrors + ); + router.post( + '/transaction/:id/categorize/expense', + this.categorizeAsExpenseValidationSchema, + this.validationResult, + this.categorizesCashflowTransactionAsExpense, + this.catchServiceErrors + ); return router; } + /** + * Getting uncategorized transactions validation schema. + * @returns {ValidationChain} + */ + public get getUncategorizedTransactionsValidationSchema() { + return [ + param('id').exists().isNumeric().toInt(), + query('page').optional().isNumeric().toInt(), + query('page_size').optional().isNumeric().toInt(), + ]; + } + + /** + * Categorize as expense validation schema. + */ + public get categorizeAsExpenseValidationSchema() { + return [ + check('expense_account_id').exists(), + check('date').isISO8601().exists(), + check('reference_no').optional(), + check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(), + ]; + } + + /** + * Categorize cashflow tranasction validation schema. + */ + public get categorizeCashflowTransactionValidationSchema() { + return [ + check('date').exists().isISO8601().toDate(), + check('credit_account_id').exists().isInt().toInt(), + check('transaction_number').optional(), + check('transaction_type').exists(), + check('reference_no').optional(), + check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(), + check('description').optional(), + check('branch_id').optional({ nullable: true }).isNumeric().toInt(), + ]; + } + /** * New cashflow transaction validation schema. */ - get newTransactionValidationSchema() { + public get newTransactionValidationSchema() { return [ check('date').exists().isISO8601().toDate(), check('reference_no').optional({ nullable: true }).trim().escape(), @@ -48,9 +119,7 @@ export default class NewCashflowTransactionController extends BaseController { check('credit_account_id').exists().isInt().toInt(), check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(), - check('branch_id').optional({ nullable: true }).isNumeric().toInt(), - check('publish').default(false).isBoolean().toBoolean(), ]; } @@ -70,13 +139,12 @@ export default class NewCashflowTransactionController extends BaseController { const ownerContributionDTO = this.matchedBodyData(req); try { - const { cashflowTransaction } = - await this.newCashflowTranscationService.newCashflowTransaction( + const cashflowTransaction = + await this.cashflowApplication.createTransaction( tenantId, ownerContributionDTO, userId ); - return res.status(200).send({ id: cashflowTransaction.id, message: 'New cashflow transaction has been created successfully.', @@ -86,11 +154,147 @@ export default class NewCashflowTransactionController extends BaseController { } }; + /** + * Revert the categorized cashflow transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private revertCategorizedCashflowTransaction = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: cashflowTransactionId } = req.params; + + try { + const data = await this.cashflowApplication.uncategorizeTransaction( + tenantId, + cashflowTransactionId + ); + return res.status(200).send({ data }); + } catch (error) { + next(error); + } + }; + + /** + * Categorize the cashflow transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private categorizeCashflowTransaction = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: cashflowTransactionId } = req.params; + const cashflowTransaction = this.matchedBodyData(req); + + try { + await this.cashflowApplication.categorizeTransaction( + tenantId, + cashflowTransactionId, + cashflowTransaction + ); + return res.status(200).send({ + message: 'The cashflow transaction has been created successfully.', + }); + } catch (error) { + next(error); + } + }; + + /** + * Categorize the transaction as expense transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private categorizesCashflowTransactionAsExpense = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: cashflowTransactionId } = req.params; + const cashflowTransaction = this.matchedBodyData(req); + + try { + await this.cashflowApplication.categorizeAsExpense( + tenantId, + cashflowTransactionId, + cashflowTransaction + ); + return res.status(200).send({ + message: 'The cashflow transaction has been created successfully.', + }); + } catch (error) { + next(error); + } + }; + + /** + * Retrieves the uncategorized cashflow transactions. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public getUncategorizedCashflowTransaction = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: transactionId } = req.params; + + try { + const data = await this.cashflowApplication.getUncategorizedTransaction( + tenantId, + transactionId + ); + return res.status(200).send({ data }); + } catch (error) { + next(error); + } + }; + + /** + * Retrieves the uncategorized cashflow transactions. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public getUncategorizedCashflowTransactions = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: accountId } = req.params; + const query = this.matchedQueryData(req); + + try { + const data = await this.cashflowApplication.getUncategorizedTransactions( + tenantId, + accountId, + query + ); + + return res.status(200).send(data); + } catch (error) { + next(error); + } + }; + /** * Handle the service errors. * @param error - * @param req - * @param res + * @param {Request} req + * @param {res * @param next * @returns */ @@ -140,6 +344,16 @@ export default class NewCashflowTransactionController extends BaseController { ], }); } + if (error.errorType === 'UNCATEGORIZED_TRANSACTION_TYPE_INVALID') { + return res.boom.badRequest(null, { + errors: [ + { + type: 'UNCATEGORIZED_TRANSACTION_TYPE_INVALID', + code: 4100, + }, + ], + }); + } } next(error); } diff --git a/packages/server/src/api/controllers/Contacts/Contacts.ts b/packages/server/src/api/controllers/Contacts/Contacts.ts index a05ee93fd..24b99e09f 100644 --- a/packages/server/src/api/controllers/Contacts/Contacts.ts +++ b/packages/server/src/api/controllers/Contacts/Contacts.ts @@ -26,27 +26,27 @@ export default class ContactsController extends BaseController { [...this.autocompleteQuerySchema], this.validationResult, this.asyncMiddleware(this.autocompleteContacts.bind(this)), - this.dynamicListService.handlerErrorsToResponse + this.dynamicListService.handlerErrorsToResponse, ); router.get( '/:id', [param('id').exists().isNumeric().toInt()], this.validationResult, - this.asyncMiddleware(this.getContact.bind(this)) + this.asyncMiddleware(this.getContact.bind(this)), ); router.post( '/:id/inactivate', [param('id').exists().isNumeric().toInt()], this.validationResult, this.asyncMiddleware(this.inactivateContact.bind(this)), - this.handlerServiceErrors + this.handlerServiceErrors, ); router.post( '/:id/activate', [param('id').exists().isNumeric().toInt()], this.validationResult, this.asyncMiddleware(this.activateContact.bind(this)), - this.handlerServiceErrors + this.handlerServiceErrors, ); return router; } @@ -77,7 +77,7 @@ export default class ContactsController extends BaseController { try { const contact = await this.contactsService.getContact( tenantId, - contactId + contactId, ); return res.status(200).send({ customer: this.transfromToResponse(contact), @@ -105,7 +105,7 @@ export default class ContactsController extends BaseController { try { const contacts = await this.contactsService.autocompleteContacts( tenantId, - filter + filter, ); return res.status(200).send({ contacts }); } catch (error) { @@ -153,7 +153,6 @@ export default class ContactsController extends BaseController { check('email') .optional({ nullable: true }) .isString() - .normalizeEmail() .isEmail() .isLength({ max: DATATYPES_LENGTH.STRING }), check('website') @@ -380,7 +379,7 @@ export default class ContactsController extends BaseController { error: Error, req: Request, res: Response, - next: NextFunction + next: NextFunction, ) { if (error instanceof ServiceError) { if (error.errorType === 'contact_not_found') { diff --git a/packages/server/src/api/controllers/ExchangeRates.ts b/packages/server/src/api/controllers/ExchangeRates.ts index 4b808e921..63c476bf9 100644 --- a/packages/server/src/api/controllers/ExchangeRates.ts +++ b/packages/server/src/api/controllers/ExchangeRates.ts @@ -1,19 +1,16 @@ import { Service, Inject } from 'typedi'; import { Router, Request, Response, NextFunction } from 'express'; -import { check, param, query } from 'express-validator'; +import { query, oneOf } from 'express-validator'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import BaseController from './BaseController'; import { ServiceError } from '@/exceptions'; -import ExchangeRatesService from '@/services/ExchangeRates/ExchangeRatesService'; -import DynamicListingService from '@/services/DynamicListing/DynamicListService'; +import { EchangeRateErrors } from '@/lib/ExchangeRate/types'; +import { ExchangeRateApplication } from '@/services/ExchangeRates/ExchangeRateApplication'; @Service() export default class ExchangeRatesController extends BaseController { @Inject() - exchangeRatesService: ExchangeRatesService; - - @Inject() - dynamicListService: DynamicListingService; + private exchangeRatesApp: ExchangeRateApplication; /** * Constructor method. @@ -22,164 +19,40 @@ export default class ExchangeRatesController extends BaseController { const router = Router(); router.get( - '/', - [...this.exchangeRatesListSchema], + '/latest', + [ + oneOf([ + query('to_currency').exists().isString().isISO4217(), + query('from_currency').exists().isString().isISO4217(), + ]), + ], this.validationResult, - asyncMiddleware(this.exchangeRates.bind(this)), - this.dynamicListService.handlerErrorsToResponse, - this.handleServiceError, - ); - router.post( - '/', - [...this.exchangeRateDTOSchema], - this.validationResult, - asyncMiddleware(this.addExchangeRate.bind(this)), - this.handleServiceError - ); - router.post( - '/:id', - [...this.exchangeRateEditDTOSchema, ...this.exchangeRateIdSchema], - this.validationResult, - asyncMiddleware(this.editExchangeRate.bind(this)), - this.handleServiceError - ); - router.delete( - '/:id', - [...this.exchangeRateIdSchema], - this.validationResult, - asyncMiddleware(this.deleteExchangeRate.bind(this)), + asyncMiddleware(this.latestExchangeRate.bind(this)), this.handleServiceError ); return router; } - get exchangeRatesListSchema() { - return [ - query('page').optional().isNumeric().toInt(), - query('page_size').optional().isNumeric().toInt(), - - query('column_sort_by').optional(), - query('sort_order').optional().isIn(['desc', 'asc']), - ]; - } - - get exchangeRateDTOSchema() { - return [ - check('exchange_rate').exists().isNumeric().toFloat(), - check('currency_code').exists().trim().escape(), - check('date').exists().isISO8601(), - ]; - } - - get exchangeRateEditDTOSchema() { - return [check('exchange_rate').exists().isNumeric().toFloat()]; - } - - get exchangeRateIdSchema() { - return [param('id').isNumeric().toInt()]; - } - - get exchangeRatesIdsSchema() { - return [ - query('ids').isArray({ min: 2 }), - query('ids.*').isNumeric().toInt(), - ]; - } - /** * Retrieve exchange rates. * @param {Request} req * @param {Response} res * @param {NextFunction} next */ - async exchangeRates(req: Request, res: Response, next: NextFunction) { + private async latestExchangeRate( + req: Request, + res: Response, + next: NextFunction + ) { const { tenantId } = req; - const filter = { - page: 1, - pageSize: 12, - filterRoles: [], - columnSortBy: 'created_at', - sortOrder: 'asc', - ...this.matchedQueryData(req), - }; - if (filter.stringifiedFilterRoles) { - filter.filterRoles = JSON.parse(filter.stringifiedFilterRoles); - } - try { - const exchangeRates = await this.exchangeRatesService.listExchangeRates( - tenantId, - filter - ); - return res.status(200).send({ exchange_rates: exchangeRates }); - } catch (error) { - next(error); - } - } - - /** - * Adds a new exchange rate on the given date. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async addExchangeRate(req: Request, res: Response, next: NextFunction) { - const { tenantId } = req; - const exchangeRateDTO = this.matchedBodyData(req); + const exchangeRateQuery = this.matchedQueryData(req); try { - const exchangeRate = await this.exchangeRatesService.newExchangeRate( + const exchangeRate = await this.exchangeRatesApp.latest( tenantId, - exchangeRateDTO + exchangeRateQuery ); - return res.status(200).send({ id: exchangeRate.id }); - } catch (error) { - next(error); - } - } - - /** - * Edit the given exchange rate. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async editExchangeRate(req: Request, res: Response, next: NextFunction) { - const { tenantId } = req; - const { id: exchangeRateId } = req.params; - const exchangeRateDTO = this.matchedBodyData(req); - - try { - const exchangeRate = await this.exchangeRatesService.editExchangeRate( - tenantId, - exchangeRateId, - exchangeRateDTO - ); - - return res.status(200).send({ - id: exchangeRateId, - message: 'The exchange rate has been edited successfully.', - }); - } catch (error) { - next(error); - } - } - - /** - * Delete the given exchange rate from the storage. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async deleteExchangeRate(req: Request, res: Response, next: NextFunction) { - const { tenantId } = req; - const { id: exchangeRateId } = req.params; - - try { - await this.exchangeRatesService.deleteExchangeRate( - tenantId, - exchangeRateId - ); - return res.status(200).send({ id: exchangeRateId }); + return res.status(200).send(exchangeRate); } catch (error) { next(error); } @@ -192,26 +65,56 @@ export default class ExchangeRatesController extends BaseController { * @param {Response} res * @param {NextFunction} next */ - handleServiceError( + private handleServiceError( error: Error, req: Request, res: Response, next: NextFunction ) { if (error instanceof ServiceError) { - if (error.errorType === 'EXCHANGE_RATE_NOT_FOUND') { - return res.status(404).send({ - errors: [{ type: 'EXCHANGE.RATE.NOT.FOUND', code: 200 }], - }); - } - if (error.errorType === 'NOT_FOUND_EXCHANGE_RATES') { + if (EchangeRateErrors.EX_RATE_INVALID_BASE_CURRENCY === error.errorType) { return res.status(400).send({ - errors: [{ type: 'EXCHANGE.RATES.IS.NOT.FOUND', code: 100 }], + errors: [ + { + type: EchangeRateErrors.EX_RATE_INVALID_BASE_CURRENCY, + code: 100, + message: 'The given base currency is invalid.', + }, + ], }); - } - if (error.errorType === 'EXCHANGE_RATE_PERIOD_EXISTS') { + } else if ( + EchangeRateErrors.EX_RATE_SERVICE_NOT_ALLOWED === error.errorType + ) { return res.status(400).send({ - errors: [{ type: 'EXCHANGE.RATE.PERIOD.EXISTS', code: 300 }], + errors: [ + { + type: EchangeRateErrors.EX_RATE_SERVICE_NOT_ALLOWED, + code: 200, + message: 'The service is not allowed', + }, + ], + }); + } else if ( + EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED === error.errorType + ) { + return res.status(400).send({ + errors: [ + { + type: EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED, + code: 300, + message: 'The API key is required', + }, + ], + }); + } else if (EchangeRateErrors.EX_RATE_LIMIT_EXCEEDED === error.errorType) { + return res.status(400).send({ + errors: [ + { + type: EchangeRateErrors.EX_RATE_LIMIT_EXCEEDED, + code: 400, + message: 'The API rate limit has been exceeded', + }, + ], }); } } diff --git a/packages/server/src/api/controllers/FinancialStatements/APAgingSummary.ts b/packages/server/src/api/controllers/FinancialStatements/APAgingSummary.ts index 5d626896c..d87873a2b 100644 --- a/packages/server/src/api/controllers/FinancialStatements/APAgingSummary.ts +++ b/packages/server/src/api/controllers/FinancialStatements/APAgingSummary.ts @@ -71,6 +71,7 @@ export default class APAgingSummaryReportController extends BaseFinancialReportC ACCEPT_TYPE.APPLICATION_JSON_TABLE, ACCEPT_TYPE.APPLICATION_CSV, ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_PDF ]); // Retrieves the json table format. if (ACCEPT_TYPE.APPLICATION_JSON_TABLE === acceptType) { @@ -98,6 +99,15 @@ export default class APAgingSummaryReportController extends BaseFinancialReportC 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ); return res.send(buffer); + // Retrieves the pdf format. + } else if (ACCEPT_TYPE.APPLICATION_PDF === acceptType) { + const pdfContent = await this.APAgingSummaryApp.pdf(tenantId, filter); + + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, + }); + return res.send(pdfContent); // Retrieves the json format. } else { const sheet = await this.APAgingSummaryApp.sheet(tenantId, filter); diff --git a/packages/server/src/api/controllers/FinancialStatements/ARAgingSummary.ts b/packages/server/src/api/controllers/FinancialStatements/ARAgingSummary.ts index 10e42e900..86b4b920a 100644 --- a/packages/server/src/api/controllers/FinancialStatements/ARAgingSummary.ts +++ b/packages/server/src/api/controllers/FinancialStatements/ARAgingSummary.ts @@ -11,7 +11,7 @@ import { ACCEPT_TYPE } from '@/interfaces/Http'; @Service() export default class ARAgingSummaryReportController extends BaseFinancialReportController { @Inject() - ARAgingSummaryApp: ARAgingSummaryApplication; + private ARAgingSummaryApp: ARAgingSummaryApplication; /** * Router constructor. @@ -69,6 +69,7 @@ export default class ARAgingSummaryReportController extends BaseFinancialReportC ACCEPT_TYPE.APPLICATION_JSON_TABLE, ACCEPT_TYPE.APPLICATION_CSV, ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_PDF ]); // Retrieves the xlsx format. if (ACCEPT_TYPE.APPLICATION_XLSX === acceptType) { @@ -96,6 +97,15 @@ export default class ARAgingSummaryReportController extends BaseFinancialReportC res.setHeader('Content-Type', 'text/csv'); return res.send(buffer); + // Retrieves the pdf format. + } else if (ACCEPT_TYPE.APPLICATION_PDF === acceptType) { + const pdfContent = await this.ARAgingSummaryApp.pdf(tenantId, filter); + + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, + }); + return res.send(pdfContent); // Retrieves the json format. } else { const sheet = await this.ARAgingSummaryApp.sheet(tenantId, filter); diff --git a/packages/server/src/api/controllers/FinancialStatements/BalanceSheet.ts b/packages/server/src/api/controllers/FinancialStatements/BalanceSheet.ts index 0af53d723..fda717a38 100644 --- a/packages/server/src/api/controllers/FinancialStatements/BalanceSheet.ts +++ b/packages/server/src/api/controllers/FinancialStatements/BalanceSheet.ts @@ -101,6 +101,7 @@ export default class BalanceSheetStatementController extends BaseFinancialReport ACCEPT_TYPE.APPLICATION_JSON_TABLE, ACCEPT_TYPE.APPLICATION_XLSX, ACCEPT_TYPE.APPLICATION_CSV, + ACCEPT_TYPE.APPLICATION_PDF, ]); // Retrieves the json table format. if (ACCEPT_TYPE.APPLICATION_JSON_TABLE == acceptType) { @@ -128,6 +129,15 @@ export default class BalanceSheetStatementController extends BaseFinancialReport 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ); return res.send(buffer); + // Retrieves the pdf format. + } else if (ACCEPT_TYPE.APPLICATION_PDF === acceptType) { + const pdfContent = await this.balanceSheetApp.pdf(tenantId, filter); + + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, + }); + res.send(pdfContent); } else { const sheet = await this.balanceSheetApp.sheet(tenantId, filter); diff --git a/packages/server/src/api/controllers/FinancialStatements/CashFlow/CashFlow.ts b/packages/server/src/api/controllers/FinancialStatements/CashFlow/CashFlow.ts index bab04246d..bc24b5379 100644 --- a/packages/server/src/api/controllers/FinancialStatements/CashFlow/CashFlow.ts +++ b/packages/server/src/api/controllers/FinancialStatements/CashFlow/CashFlow.ts @@ -79,6 +79,7 @@ export default class CashFlowController extends BaseFinancialReportController { ACCEPT_TYPE.APPLICATION_JSON_TABLE, ACCEPT_TYPE.APPLICATION_CSV, ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_PDF ]); // Retrieves the json table format. if (ACCEPT_TYPE.APPLICATION_JSON_TABLE === acceptType) { @@ -106,6 +107,15 @@ export default class CashFlowController extends BaseFinancialReportController { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ); return res.send(buffer); + // Retrieves the pdf format. + } else if (ACCEPT_TYPE.APPLICATION_PDF === acceptType) { + const pdfContent = await this.cashflowSheetApp.pdf(tenantId, filter); + + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, + }); + return res.send(pdfContent); // Retrieves the json format. } else { const cashflow = await this.cashflowSheetApp.sheet(tenantId, filter); diff --git a/packages/server/src/api/controllers/FinancialStatements/CustomerBalanceSummary/index.ts b/packages/server/src/api/controllers/FinancialStatements/CustomerBalanceSummary/index.ts index 6c10543f5..e69fcdc1c 100644 --- a/packages/server/src/api/controllers/FinancialStatements/CustomerBalanceSummary/index.ts +++ b/packages/server/src/api/controllers/FinancialStatements/CustomerBalanceSummary/index.ts @@ -75,6 +75,7 @@ export default class CustomerBalanceSummaryReportController extends BaseFinancia ACCEPT_TYPE.APPLICATION_JSON_TABLE, ACCEPT_TYPE.APPLICATION_CSV, ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_PDF, ]); // Retrieves the xlsx format. @@ -109,6 +110,19 @@ export default class CustomerBalanceSummaryReportController extends BaseFinancia filter ); return res.status(200).send(table); + // Retrieves the pdf format. + } else if (ACCEPT_TYPE.APPLICATION_PDF === acceptType) { + const buffer = await this.customerBalanceSummaryApp.pdf( + tenantId, + filter + ); + + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': buffer.length, + }); + return res.send(buffer); + // Retrieves the json format. } else { const sheet = await this.customerBalanceSummaryApp.sheet( tenantId, diff --git a/packages/server/src/api/controllers/FinancialStatements/GeneralLedger.ts b/packages/server/src/api/controllers/FinancialStatements/GeneralLedger.ts index ac3f002a7..188178a01 100644 --- a/packages/server/src/api/controllers/FinancialStatements/GeneralLedger.ts +++ b/packages/server/src/api/controllers/FinancialStatements/GeneralLedger.ts @@ -2,20 +2,21 @@ import { Router, Request, Response, NextFunction } from 'express'; import { query, ValidationChain } from 'express-validator'; import { Inject, Service } from 'typedi'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; -import GeneralLedgerService from '@/services/FinancialStatements/GeneralLedger/GeneralLedgerService'; import BaseFinancialReportController from './BaseFinancialReportController'; import { AbilitySubject, ReportsAction } from '@/interfaces'; import CheckPolicies from '@/api/middleware/CheckPolicies'; +import { ACCEPT_TYPE } from '@/interfaces/Http'; +import { GeneralLedgerApplication } from '@/services/FinancialStatements/GeneralLedger/GeneralLedgerApplication'; @Service() export default class GeneralLedgerReportController extends BaseFinancialReportController { @Inject() - generalLedgetService: GeneralLedgerService; + private generalLedgerApplication: GeneralLedgerApplication; /** * Router constructor. */ - router() { + public router() { const router = Router(); router.get( @@ -31,7 +32,7 @@ export default class GeneralLedgerReportController extends BaseFinancialReportCo /** * Validation schema. */ - get validationSchema(): ValidationChain[] { + private get validationSchema(): ValidationChain[] { return [ query('from_date').optional().isISO8601(), query('to_date').optional().isISO8601(), @@ -60,21 +61,56 @@ export default class GeneralLedgerReportController extends BaseFinancialReportCo * @param {Request} req - * @param {Response} res - */ - async generalLedger(req: Request, res: Response, next: NextFunction) { - const { tenantId, settings } = req; + private async generalLedger(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; const filter = this.matchedQueryData(req); + const accept = this.accepts(req); - try { - const { data, query, meta } = - await this.generalLedgetService.generalLedger(tenantId, filter); + const acceptType = accept.types([ + ACCEPT_TYPE.APPLICATION_JSON, + ACCEPT_TYPE.APPLICATION_JSON_TABLE, + ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_CSV, + ACCEPT_TYPE.APPLICATION_PDF, + ]); + // Retrieves the table format. + if (ACCEPT_TYPE.APPLICATION_JSON_TABLE === acceptType) { + const table = await this.generalLedgerApplication.table(tenantId, filter); - return res.status(200).send({ - meta: this.transfromToResponse(meta), - data: this.transfromToResponse(data), - query: this.transfromToResponse(query), + return res.status(200).send(table); + // Retrieves the csv format. + } else if (ACCEPT_TYPE.APPLICATION_CSV === acceptType) { + const buffer = await this.generalLedgerApplication.csv(tenantId, filter); + + res.setHeader('Content-Disposition', 'attachment; filename=output.csv'); + res.setHeader('Content-Type', 'text/csv'); + + return res.send(buffer); + // Retrieves the xlsx format. + } else if (ACCEPT_TYPE.APPLICATION_XLSX === acceptType) { + const buffer = await this.generalLedgerApplication.xlsx(tenantId, filter); + + res.setHeader('Content-Disposition', 'attachment; filename=output.xlsx'); + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ); + return res.send(buffer); + // Retrieves the pdf format. + } else if (ACCEPT_TYPE.APPLICATION_PDF === acceptType) { + const pdfContent = await this.generalLedgerApplication.pdf( + tenantId, + filter + ); + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, }); - } catch (error) { - next(error); + return res.send(pdfContent); + // Retrieves the json format. + } else { + const sheet = await this.generalLedgerApplication.sheet(tenantId, filter); + return res.status(200).send(sheet); } } } diff --git a/packages/server/src/api/controllers/FinancialStatements/InventoryDetails/index.ts b/packages/server/src/api/controllers/FinancialStatements/InventoryDetails/index.ts index 07f91af4a..3288ee847 100644 --- a/packages/server/src/api/controllers/FinancialStatements/InventoryDetails/index.ts +++ b/packages/server/src/api/controllers/FinancialStatements/InventoryDetails/index.ts @@ -96,6 +96,7 @@ export default class InventoryDetailsController extends BaseController { ACCEPT_TYPE.APPLICATION_JSON_TABLE, ACCEPT_TYPE.APPLICATION_CSV, ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_PDF, ]); // Retrieves the csv format. if (acceptType === ACCEPT_TYPE.APPLICATION_CSV) { @@ -127,6 +128,15 @@ export default class InventoryDetailsController extends BaseController { filter ); return res.status(200).send(table); + // Retrieves the pdf format. + } else if (acceptType === ACCEPT_TYPE.APPLICATION_PDF) { + const buffer = await this.inventoryItemDetailsApp.pdf(tenantId, filter); + + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': buffer.length, + }); + return res.send(buffer); } else { const sheet = await this.inventoryItemDetailsApp.sheet( tenantId, diff --git a/packages/server/src/api/controllers/FinancialStatements/InventoryValuationSheet.ts b/packages/server/src/api/controllers/FinancialStatements/InventoryValuationSheet.ts index a98c8c997..b31a911a2 100644 --- a/packages/server/src/api/controllers/FinancialStatements/InventoryValuationSheet.ts +++ b/packages/server/src/api/controllers/FinancialStatements/InventoryValuationSheet.ts @@ -3,14 +3,15 @@ import { query, ValidationChain } from 'express-validator'; import { Inject, Service } from 'typedi'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import BaseFinancialReportController from './BaseFinancialReportController'; -import InventoryValuationService from '@/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetService'; import { AbilitySubject, ReportsAction } from '@/interfaces'; import CheckPolicies from '@/api/middleware/CheckPolicies'; +import { InventoryValuationSheetApplication } from '@/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetApplication'; +import { ACCEPT_TYPE } from '@/interfaces/Http'; @Service() export default class InventoryValuationReportController extends BaseFinancialReportController { @Inject() - inventoryValuationService: InventoryValuationService; + private inventoryValuationApp: InventoryValuationSheetApplication; /** * Router constructor. @@ -71,19 +72,55 @@ export default class InventoryValuationReportController extends BaseFinancialRep const { tenantId } = req; const filter = this.matchedQueryData(req); - try { - const { data, query, meta } = - await this.inventoryValuationService.inventoryValuationSheet( - tenantId, - filter - ); - return res.status(200).send({ - meta: this.transfromToResponse(meta), - data: this.transfromToResponse(data), - query: this.transfromToResponse(query), + const accept = this.accepts(req); + + const acceptType = accept.types([ + ACCEPT_TYPE.APPLICATION_JSON, + ACCEPT_TYPE.APPLICATION_JSON_TABLE, + ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_CSV, + ACCEPT_TYPE.APPLICATION_PDF, + ]); + + // Retrieves the json table format. + if (ACCEPT_TYPE.APPLICATION_JSON_TABLE === acceptType) { + const table = await this.inventoryValuationApp.table(tenantId, filter); + + return res.status(200).send(table); + // Retrieves the csv format. + } else if (ACCEPT_TYPE.APPLICATION_CSV == acceptType) { + const buffer = await this.inventoryValuationApp.csv(tenantId, filter); + + res.setHeader('Content-Disposition', 'attachment; filename=output.csv'); + res.setHeader('Content-Type', 'text/csv'); + + return res.send(buffer); + // Retrieves the xslx buffer format. + } else if (ACCEPT_TYPE.APPLICATION_XLSX === acceptType) { + const buffer = await this.inventoryValuationApp.xlsx(tenantId, filter); + + res.setHeader('Content-Disposition', 'attachment; filename=output.xlsx'); + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ); + return res.send(buffer); + // Retrieves the pdf format. + } else if (ACCEPT_TYPE.APPLICATION_PDF === acceptType) { + const pdfContent = await this.inventoryValuationApp.pdf(tenantId, filter); + + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, }); - } catch (error) { - next(error); + return res.status(200).send(pdfContent); + // Retrieves the json format. + } else { + const { data, query, meta } = await this.inventoryValuationApp.sheet( + tenantId, + filter + ); + return res.status(200).send({ meta, data, query }); } } } diff --git a/packages/server/src/api/controllers/FinancialStatements/JournalSheet.ts b/packages/server/src/api/controllers/FinancialStatements/JournalSheet.ts index ebd6074f4..561f69329 100644 --- a/packages/server/src/api/controllers/FinancialStatements/JournalSheet.ts +++ b/packages/server/src/api/controllers/FinancialStatements/JournalSheet.ts @@ -3,14 +3,15 @@ import { Request, Response, Router, NextFunction } from 'express'; import { castArray } from 'lodash'; import { query, oneOf } from 'express-validator'; import BaseFinancialReportController from './BaseFinancialReportController'; -import JournalSheetService from '@/services/FinancialStatements/JournalSheet/JournalSheetService'; import { AbilitySubject, ReportsAction } from '@/interfaces'; import CheckPolicies from '@/api/middleware/CheckPolicies'; +import { ACCEPT_TYPE } from '@/interfaces/Http'; +import { JournalSheetApplication } from '@/services/FinancialStatements/JournalSheet/JournalSheetApplication'; @Service() export default class JournalSheetController extends BaseFinancialReportController { @Inject() - journalService: JournalSheetService; + private journalSheetApp: JournalSheetApplication; /** * Router constructor. @@ -57,28 +58,58 @@ export default class JournalSheetController extends BaseFinancialReportControlle * @param {Request} req - * @param {Response} res - */ - async journal(req: Request, res: Response, next: NextFunction) { - const { tenantId, settings } = req; + private async journal(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; let filter = this.matchedQueryData(req); filter = { ...filter, accountsIds: castArray(filter.accountsIds), }; + const accept = this.accepts(req); + const acceptType = accept.types([ + ACCEPT_TYPE.APPLICATION_JSON, + ACCEPT_TYPE.APPLICATION_JSON_TABLE, + ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_CSV, + ACCEPT_TYPE.APPLICATION_PDF, + ]); - try { - const { data, query, meta } = await this.journalService.journalSheet( - tenantId, - filter + // Retrieves the json table format. + if (ACCEPT_TYPE.APPLICATION_JSON_TABLE === acceptType) { + const table = await this.journalSheetApp.table(tenantId, filter); + return res.status(200).send(table); + // Retrieves the csv format. + } else if (ACCEPT_TYPE.APPLICATION_CSV === acceptType) { + const buffer = await this.journalSheetApp.csv(tenantId, filter); + + res.setHeader('Content-Disposition', 'attachment; filename=output.csv'); + res.setHeader('Content-Type', 'text/csv'); + + return res.send(buffer); + // Retrieves the xlsx format. + } else if (ACCEPT_TYPE.APPLICATION_XLSX === acceptType) { + const buffer = await this.journalSheetApp.xlsx(tenantId, filter); + + 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 (ACCEPT_TYPE.APPLICATION_PDF === acceptType) { + const pdfContent = await this.journalSheetApp.pdf(tenantId, filter); - return res.status(200).send({ - data: this.transfromToResponse(data), - query: this.transfromToResponse(query), - meta: this.transfromToResponse(meta), + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, }); - } catch (error) { - next(error); + res.send(pdfContent); + } else { + const sheet = await this.journalSheetApp.sheet(tenantId, filter); + + return res.status(200).send(sheet); } } } diff --git a/packages/server/src/api/controllers/FinancialStatements/ProfitLossSheet.ts b/packages/server/src/api/controllers/FinancialStatements/ProfitLossSheet.ts index 8c2404335..995df07b4 100644 --- a/packages/server/src/api/controllers/FinancialStatements/ProfitLossSheet.ts +++ b/packages/server/src/api/controllers/FinancialStatements/ProfitLossSheet.ts @@ -96,6 +96,7 @@ export default class ProfitLossSheetController extends BaseFinancialReportContro ACCEPT_TYPE.APPLICATION_JSON_TABLE, ACCEPT_TYPE.APPLICATION_CSV, ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_PDF, ]); try { // Retrieves the csv format. @@ -125,6 +126,14 @@ export default class ProfitLossSheetController extends BaseFinancialReportContro ); return res.send(sheet); // Retrieves the json format. + } else if (acceptType === ACCEPT_TYPE.APPLICATION_PDF) { + const pdfContent = await this.profitLossSheetApp.pdf(tenantId, filter); + + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, + }); + return res.send(pdfContent); } else { const sheet = await this.profitLossSheetApp.sheet(tenantId, filter); diff --git a/packages/server/src/api/controllers/FinancialStatements/PurchasesByItem.ts b/packages/server/src/api/controllers/FinancialStatements/PurchasesByItem.ts index 5e7aacb09..2c92bdc97 100644 --- a/packages/server/src/api/controllers/FinancialStatements/PurchasesByItem.ts +++ b/packages/server/src/api/controllers/FinancialStatements/PurchasesByItem.ts @@ -1,17 +1,18 @@ import { Router, Request, Response, NextFunction } from 'express'; import { query, ValidationChain } from 'express-validator'; -import moment from 'moment'; import { Inject, Service } from 'typedi'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import BaseFinancialReportController from './BaseFinancialReportController'; -import PurchasesByItemsService from '@/services/FinancialStatements/PurchasesByItems/PurchasesByItemsService'; +import { PurchasesByItemsService } from '@/services/FinancialStatements/PurchasesByItems/PurchasesByItemsService'; import { AbilitySubject, ReportsAction } from '@/interfaces'; import CheckPolicies from '@/api/middleware/CheckPolicies'; +import { ACCEPT_TYPE } from '@/interfaces/Http'; +import { PurcahsesByItemsApplication } from '@/services/FinancialStatements/PurchasesByItems/PurchasesByItemsApplication'; @Service() export default class PurchasesByItemReportController extends BaseFinancialReportController { @Inject() - purchasesByItemsService: PurchasesByItemsService; + private purchasesByItemsApp: PurcahsesByItemsApplication; /** * Router constructor. @@ -63,20 +64,56 @@ export default class PurchasesByItemReportController extends BaseFinancialReport * @param {Request} req - * @param {Response} res - */ - async purchasesByItems(req: Request, res: Response, next: NextFunction) { + public async purchasesByItems(req: Request, res: Response) { const { tenantId } = req; const filter = this.matchedQueryData(req); - try { - const { data, query, meta } = - await this.purchasesByItemsService.purchasesByItems(tenantId, filter); - return res.status(200).send({ - meta: this.transfromToResponse(meta), - data: this.transfromToResponse(data), - query: this.transfromToResponse(query), + const accept = this.accepts(req); + + const acceptType = accept.types([ + ACCEPT_TYPE.APPLICATION_JSON, + ACCEPT_TYPE.APPLICATION_JSON_TABLE, + ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_CSV, + ACCEPT_TYPE.APPLICATION_PDF, + ]); + // JSON table response format. + if (ACCEPT_TYPE.APPLICATION_JSON_TABLE === acceptType) { + const table = await this.purchasesByItemsApp.table(tenantId, filter); + + return res.status(200).send(table); + // CSV response format. + } else if (ACCEPT_TYPE.APPLICATION_CSV === acceptType) { + const buffer = await this.purchasesByItemsApp.csv(tenantId, filter); + + res.setHeader('Content-Disposition', 'attachment; filename=output.csv'); + res.setHeader('Content-Type', 'text/csv'); + + return res.send(buffer); + // Xlsx response format. + } else if (ACCEPT_TYPE.APPLICATION_XLSX === acceptType) { + const buffer = await this.purchasesByItemsApp.xlsx(tenantId, filter); + + res.setHeader('Content-Disposition', 'attachment; filename=output.xlsx'); + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ); + return res.send(buffer); + // PDF response format. + } else if (ACCEPT_TYPE.APPLICATION_PDF === acceptType) { + const pdfContent = await this.purchasesByItemsApp.pdf(tenantId, filter); + + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, }); - } catch (error) { - next(error); + return res.send(pdfContent); + // Json response format. + } else { + const sheet = await this.purchasesByItemsApp.sheet(tenantId, filter); + + return res.status(200).send(sheet); } } } diff --git a/packages/server/src/api/controllers/FinancialStatements/SalesByItems.ts b/packages/server/src/api/controllers/FinancialStatements/SalesByItems.ts index d31954398..28ab611c0 100644 --- a/packages/server/src/api/controllers/FinancialStatements/SalesByItems.ts +++ b/packages/server/src/api/controllers/FinancialStatements/SalesByItems.ts @@ -1,17 +1,17 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { query, ValidationChain } from 'express-validator'; -import moment from 'moment'; +import { query, ValidationChain, ValidationSchema } from 'express-validator'; import { Inject, Service } from 'typedi'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import BaseFinancialReportController from './BaseFinancialReportController'; -import SalesByItemsReportService from '@/services/FinancialStatements/SalesByItems/SalesByItemsService'; import { AbilitySubject, ReportsAction } from '@/interfaces'; import CheckPolicies from '@/api/middleware/CheckPolicies'; +import { ACCEPT_TYPE } from '@/interfaces/Http'; +import { SalesByItemsApplication } from '@/services/FinancialStatements/SalesByItems/SalesByItemsApplication'; @Service() export default class SalesByItemsReportController extends BaseFinancialReportController { @Inject() - salesByItemsService: SalesByItemsReportService; + private salesByItemsApp: SalesByItemsApplication; /** * Router constructor. @@ -24,13 +24,14 @@ export default class SalesByItemsReportController extends BaseFinancialReportCon CheckPolicies(ReportsAction.READ_SALES_BY_ITEMS, AbilitySubject.Report), this.validationSchema, this.validationResult, - asyncMiddleware(this.purchasesByItems.bind(this)) + asyncMiddleware(this.salesByItems.bind(this)) ); return router; } /** * Validation schema. + * @returns {ValidationChain[]} */ private get validationSchema(): ValidationChain[] { return [ @@ -60,26 +61,53 @@ export default class SalesByItemsReportController extends BaseFinancialReportCon * @param {Request} req - * @param {Response} res - */ - private async purchasesByItems( - req: Request, - res: Response, - next: NextFunction - ) { + private async salesByItems(req: Request, res: Response, next: NextFunction) { const { tenantId } = req; const filter = this.matchedQueryData(req); + const accept = this.accepts(req); - try { - const { data, query, meta } = await this.salesByItemsService.salesByItems( - tenantId, - filter + const acceptType = accept.types([ + ACCEPT_TYPE.APPLICATION_JSON, + ACCEPT_TYPE.APPLICATION_JSON_TABLE, + ACCEPT_TYPE.APPLICATION_CSV, + ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_PDF, + ]); + // Retrieves the csv format. + if (ACCEPT_TYPE.APPLICATION_CSV === acceptType) { + const buffer = await this.salesByItemsApp.csv(tenantId, filter); + + res.setHeader('Content-Disposition', 'attachment; filename=output.csv'); + res.setHeader('Content-Type', 'text/csv'); + + return res.send(buffer); + // Retrieves the json table format. + } else if (ACCEPT_TYPE.APPLICATION_JSON_TABLE === acceptType) { + const table = await this.salesByItemsApp.table(tenantId, filter); + + return res.status(200).send(table); + // Retrieves the xlsx format. + } else if (ACCEPT_TYPE.APPLICATION_XLSX === acceptType) { + const buffer = this.salesByItemsApp.xlsx(tenantId, filter); + + res.setHeader('Content-Disposition', 'attachment; filename=output.xlsx'); + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ); - return res.status(200).send({ - meta: this.transfromToResponse(meta), - data: this.transfromToResponse(data), - query: this.transfromToResponse(query), + return res.send(buffer); + // Retrieves the json format. + } else if (ACCEPT_TYPE.APPLICATION_PDF === acceptType) { + const pdfContent = await this.salesByItemsApp.pdf(tenantId, filter); + + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, }); - } catch (error) { - next(error); + return res.send(pdfContent); + } else { + const sheet = await this.salesByItemsApp.sheet(tenantId, filter); + return res.status(200).send(sheet); } } } diff --git a/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts b/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts index 933b5c9c4..42a96aab2 100644 --- a/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts +++ b/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts @@ -62,6 +62,7 @@ export default class SalesTaxLiabilitySummary extends BaseFinancialReportControl ACCEPT_TYPE.APPLICATION_JSON_TABLE, ACCEPT_TYPE.APPLICATION_CSV, ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_PDF, ]); // Retrieves the json table format. @@ -97,6 +98,16 @@ export default class SalesTaxLiabilitySummary extends BaseFinancialReportControl return res.send(buffer); // Retrieves the json format. + } else if (acceptType === ACCEPT_TYPE.APPLICATION_PDF) { + const pdfContent = await this.salesTaxLiabilitySummaryApp.pdf( + tenantId, + filter + ); + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, + }); + return res.status(200).send(pdfContent); } else { const sheet = await this.salesTaxLiabilitySummaryApp.sheet( tenantId, diff --git a/packages/server/src/api/controllers/FinancialStatements/TransactionsByCustomers/index.ts b/packages/server/src/api/controllers/FinancialStatements/TransactionsByCustomers/index.ts index 4bc3b1f44..c10eeea16 100644 --- a/packages/server/src/api/controllers/FinancialStatements/TransactionsByCustomers/index.ts +++ b/packages/server/src/api/controllers/FinancialStatements/TransactionsByCustomers/index.ts @@ -70,6 +70,7 @@ export default class TransactionsByCustomersReportController extends BaseFinanci ACCEPT_TYPE.APPLICATION_JSON_TABLE, ACCEPT_TYPE.APPLICATION_CSV, ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_PDF, ]); try { // Retrieves the json table format. @@ -103,6 +104,16 @@ export default class TransactionsByCustomersReportController extends BaseFinanci ); return res.send(buffer); // Retrieve the json format. + } else if (ACCEPT_TYPE.APPLICATION_PDF === acceptType) { + const pdfContent = await this.transactionsByCustomersApp.pdf( + tenantId, + filter + ); + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, + }); + return res.send(pdfContent); } else { const sheet = await this.transactionsByCustomersApp.sheet( tenantId, diff --git a/packages/server/src/api/controllers/FinancialStatements/TransactionsByVendors/index.ts b/packages/server/src/api/controllers/FinancialStatements/TransactionsByVendors/index.ts index a0c1bf037..c437892f4 100644 --- a/packages/server/src/api/controllers/FinancialStatements/TransactionsByVendors/index.ts +++ b/packages/server/src/api/controllers/FinancialStatements/TransactionsByVendors/index.ts @@ -71,6 +71,7 @@ export default class TransactionsByVendorsReportController extends BaseFinancial ACCEPT_TYPE.APPLICATION_JSON_TABLE, ACCEPT_TYPE.APPLICATION_CSV, ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_PDF, ]); // Retrieves the xlsx format. @@ -101,6 +102,17 @@ export default class TransactionsByVendorsReportController extends BaseFinancial filter ); return res.status(200).send(table); + // Retrieves the pdf format. + } else if (ACCEPT_TYPE.APPLICATION_PDF === acceptType) { + const pdfContent = await this.transactionsByVendorsApp.pdf( + tenantId, + filter + ); + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, + }); + return res.send(pdfContent); // Retrieves the json format. } else { const sheet = await this.transactionsByVendorsApp.sheet( diff --git a/packages/server/src/api/controllers/FinancialStatements/TrialBalanceSheet.ts b/packages/server/src/api/controllers/FinancialStatements/TrialBalanceSheet.ts index ce23c1071..b92e355a2 100644 --- a/packages/server/src/api/controllers/FinancialStatements/TrialBalanceSheet.ts +++ b/packages/server/src/api/controllers/FinancialStatements/TrialBalanceSheet.ts @@ -3,7 +3,6 @@ import { Request, Response, Router, NextFunction } from 'express'; import { query, ValidationChain } from 'express-validator'; import { castArray } from 'lodash'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; -import TrialBalanceSheetService from '@/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetInjectable'; import BaseFinancialReportController from './BaseFinancialReportController'; import { AbilitySubject, ReportsAction } from '@/interfaces'; import CheckPolicies from '@/api/middleware/CheckPolicies'; @@ -81,6 +80,7 @@ export default class TrialBalanceSheetController extends BaseFinancialReportCont ACCEPT_TYPE.APPLICATION_JSON_TABLE, ACCEPT_TYPE.APPLICATION_CSV, ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_PDF, ]); // Retrieves in json table format. if (acceptType === ACCEPT_TYPE.APPLICATION_JSON_TABLE) { @@ -109,6 +109,17 @@ export default class TrialBalanceSheetController extends BaseFinancialReportCont res.setHeader('Content-Type', 'text/csv'); return res.send(buffer); + // Retrieves in pdf format. + } else if (acceptType === ACCEPT_TYPE.APPLICATION_PDF) { + const pdfContent = await this.trialBalanceSheetApp.pdf( + tenantId, + filter + ); + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, + }); + res.send(pdfContent); // Retrieves in json format. } else { const { data, query, meta } = await this.trialBalanceSheetApp.sheet( diff --git a/packages/server/src/api/controllers/FinancialStatements/VendorBalanceSummary/index.ts b/packages/server/src/api/controllers/FinancialStatements/VendorBalanceSummary/index.ts index ade69cb62..f1e26a0ed 100644 --- a/packages/server/src/api/controllers/FinancialStatements/VendorBalanceSummary/index.ts +++ b/packages/server/src/api/controllers/FinancialStatements/VendorBalanceSummary/index.ts @@ -72,6 +72,7 @@ export default class VendorBalanceSummaryReportController extends BaseFinancialR ACCEPT_TYPE.APPLICATION_JSON_TABLE, ACCEPT_TYPE.APPLICATION_CSV, ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_PDF, ]); // Retrieves the csv format. @@ -100,6 +101,17 @@ export default class VendorBalanceSummaryReportController extends BaseFinancialR filter ); return res.status(200).send(table); + // Retrieves the pdf format. + } else if (acceptType === ACCEPT_TYPE.APPLICATION_PDF) { + const pdfContent = await this.vendorBalanceSummaryApp.pdf( + tenantId, + filter + ); + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, + }); + return res.send(pdfContent); // Retrieves the json format. } else { const sheet = await this.vendorBalanceSummaryApp.sheet( diff --git a/packages/server/src/api/controllers/Purchases/Bills.ts b/packages/server/src/api/controllers/Purchases/Bills.ts index 2cd9e62de..4f65eab26 100644 --- a/packages/server/src/api/controllers/Purchases/Bills.ts +++ b/packages/server/src/api/controllers/Purchases/Bills.ts @@ -303,7 +303,7 @@ export default class BillsController extends BaseController { try { const bill = await this.billsApplication.getBill(tenantId, billId); - return res.status(200).send(this.transfromToResponse({ bill })); + return res.status(200).send({ bill }); } catch (error) { next(error); } @@ -348,14 +348,11 @@ export default class BillsController extends BaseController { }; try { - const { bills, pagination, filterMeta } = - await this.billsApplication.getBills(tenantId, filter); - - return res.status(200).send({ - bills: this.transfromToResponse(bills), - pagination: this.transfromToResponse(pagination), - filter_meta: this.transfromToResponse(filterMeta), - }); + const billsWithPagination = await this.billsApplication.getBills( + tenantId, + filter + ); + return res.status(200).send(billsWithPagination); } catch (error) { next(error); } @@ -563,6 +560,16 @@ export default class BillsController extends BaseController { errors: [{ type: 'ITEM_ENTRY_TAX_RATE_ID_NOT_FOUND', code: 1900 }], }); } + if (error.errorType === 'BILL_AMOUNT_SMALLER_THAN_PAID_AMOUNT') { + return res.boom.badRequest(null, { + errors: [ + { + type: 'BILL_AMOUNT_SMALLER_THAN_PAID_AMOUNT', + code: 2000, + }, + ], + }); + } } next(error); } diff --git a/packages/server/src/api/controllers/Purchases/BillsPayments.ts b/packages/server/src/api/controllers/Purchases/BillsPayments.ts index a663a3032..984ab6fba 100644 --- a/packages/server/src/api/controllers/Purchases/BillsPayments.ts +++ b/packages/server/src/api/controllers/Purchases/BillsPayments.ts @@ -158,15 +158,11 @@ export default class BillsPayments extends BaseController { const { tenantId } = req; const { vendorId } = this.matchedQueryData(req); - try { - const entries = await this.billPaymentsPages.getNewPageEntries( - tenantId, - vendorId - ); - return res.status(200).send({ - entries: this.transfromToResponse(entries), - }); - } catch (error) {} + const entries = await this.billPaymentsPages.getNewPageEntries( + tenantId, + vendorId + ); + return res.status(200).send({ entries }); } /** @@ -183,16 +179,12 @@ export default class BillsPayments extends BaseController { const { id: paymentReceiveId } = req.params; try { - const { billPayment, entries } = + const billPaymentsWithEditEntries = await this.billPaymentsPages.getBillPaymentEditPage( tenantId, paymentReceiveId ); - - return res.status(200).send({ - bill_payment: this.transfromToResponse(billPayment), - entries: this.transfromToResponse(entries), - }); + return res.status(200).send(billPaymentsWithEditEntries); } catch (error) { next(error); } @@ -304,9 +296,7 @@ export default class BillsPayments extends BaseController { tenantId, billPaymentId ); - return res.status(200).send({ - bill_payment: this.transfromToResponse(billPayment), - }); + return res.status(200).send({ billPayment }); } catch (error) { next(error); } @@ -359,17 +349,12 @@ export default class BillsPayments extends BaseController { }; try { - const { billPayments, pagination, filterMeta } = + const billPaymentsWithPagination = await this.billPaymentsApplication.getBillPayments( tenantId, billPaymentsFilter ); - - return res.status(200).send({ - bill_payments: this.transfromToResponse(billPayments), - pagination: this.transfromToResponse(pagination), - filter_meta: this.transfromToResponse(filterMeta), - }); + return res.status(200).send(billPaymentsWithPagination); } catch (error) { next(error); } diff --git a/packages/server/src/api/controllers/Purchases/VendorCredit.ts b/packages/server/src/api/controllers/Purchases/VendorCredit.ts index 3d2832308..79ae6741b 100644 --- a/packages/server/src/api/controllers/Purchases/VendorCredit.ts +++ b/packages/server/src/api/controllers/Purchases/VendorCredit.ts @@ -320,20 +320,19 @@ export default class VendorCreditController extends BaseController { res: Response, next: NextFunction ) => { - const { id: billId } = req.params; + const { id: vendorCreditId } = req.params; const { tenantId, user } = req; const vendorCreditEditDTO: IVendorCreditEditDTO = this.matchedBodyData(req); try { await this.editVendorCreditService.editVendorCredit( tenantId, - billId, - vendorCreditEditDTO, - user + vendorCreditId, + vendorCreditEditDTO ); return res.status(200).send({ - id: billId, + id: vendorCreditId, message: 'The vendor credit has been edited successfully.', }); } catch (error) { diff --git a/packages/server/src/api/controllers/Sales/CreditNotes.ts b/packages/server/src/api/controllers/Sales/CreditNotes.ts index 77b506c7d..4644b55c5 100644 --- a/packages/server/src/api/controllers/Sales/CreditNotes.ts +++ b/packages/server/src/api/controllers/Sales/CreditNotes.ts @@ -26,6 +26,7 @@ import GetCreditNoteAssociatedInvoicesToApply from '@/services/CreditNotes/GetCr import GetCreditNoteAssociatedAppliedInvoices from '@/services/CreditNotes/GetCreditNoteAssociatedAppliedInvoices'; import GetRefundCreditTransaction from '@/services/CreditNotes/GetRefundCreditNoteTransaction'; import GetCreditNotePdf from '../../../services/CreditNotes/GetCreditNotePdf'; +import { ACCEPT_TYPE } from '@/interfaces/Http'; /** * Credit notes controller. * @service @@ -293,7 +294,7 @@ export default class PaymentReceivesController extends BaseController { return [ check('from_account_id').exists().isNumeric().toInt(), check('description').optional(), - + check('amount').exists().isNumeric().toFloat(), check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(), @@ -438,7 +439,7 @@ export default class PaymentReceivesController extends BaseController { }; /** - * Retrieve the payment receive details. + * Retrieve the credit note details. * @param {Request} req * @param {Response} res * @param {NextFunction} next @@ -451,38 +452,28 @@ export default class PaymentReceivesController extends BaseController { const { tenantId } = req; const { id: creditNoteId } = req.params; - try { + const accept = this.accepts(req); + + const acceptType = accept.types([ + ACCEPT_TYPE.APPLICATION_JSON, + ACCEPT_TYPE.APPLICATION_PDF, + ]); + if (ACCEPT_TYPE.APPLICATION_PDF === acceptType) { + const pdfContent = await this.creditNotePdf.getCreditNotePdf( + tenantId, + creditNoteId + ); + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, + }); + res.send(pdfContent); + } else { const creditNote = await this.getCreditNoteService.getCreditNote( tenantId, creditNoteId ); - const ACCEPT_TYPE = { - APPLICATION_PDF: 'application/pdf', - APPLICATION_JSON: 'application/json', - }; - // Response formatter. - res.format({ - // Json content type. - [ACCEPT_TYPE.APPLICATION_JSON]: () => { - return res - .status(200) - .send({ credit_note: this.transfromToResponse(creditNote) }); - }, - // Pdf content type. - [ACCEPT_TYPE.APPLICATION_PDF]: async () => { - const pdfContent = await this.creditNotePdf.getCreditNotePdf( - tenantId, - creditNote - ); - res.set({ - 'Content-Type': 'application/pdf', - 'Content-Length': pdfContent.length, - }); - res.send(pdfContent); - }, - }); - } catch (error) { - next(error); + return res.status(200).send({ creditNote }); } }; diff --git a/packages/server/src/api/controllers/Sales/PaymentReceives.ts b/packages/server/src/api/controllers/Sales/PaymentReceives.ts index 7cfa93a00..818fcb713 100644 --- a/packages/server/src/api/controllers/Sales/PaymentReceives.ts +++ b/packages/server/src/api/controllers/Sales/PaymentReceives.ts @@ -1,10 +1,11 @@ import { Inject, Service } from 'typedi'; import { Router, Request, Response, NextFunction } from 'express'; -import { check, param, query, ValidationChain } from 'express-validator'; +import { body, check, param, query, ValidationChain } from 'express-validator'; import { AbilitySubject, IPaymentReceiveDTO, PaymentReceiveAction, + PaymentReceiveMailOptsDTO, } from '@/interfaces'; import BaseController from '@/api/controllers/BaseController'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; @@ -13,6 +14,7 @@ import DynamicListingService from '@/services/DynamicListing/DynamicListService' import { PaymentReceivesApplication } from '@/services/Sales/PaymentReceives/PaymentReceivesApplication'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { ServiceError } from '@/exceptions'; +import { ACCEPT_TYPE } from '@/interfaces/Http'; @Service() export default class PaymentReceivesController extends BaseController { @@ -117,6 +119,25 @@ export default class PaymentReceivesController extends BaseController { asyncMiddleware(this.deletePaymentReceive.bind(this)), this.handleServiceErrors ); + router.post( + '/:id/mail', + [ + ...this.paymentReceiveValidation, + body('subject').isString().optional(), + body('from').isString().optional(), + body('to').isString().optional(), + body('body').isString().optional(), + body('attach_invoice').optional().isBoolean().toBoolean(), + ], + this.sendPaymentReceiveByMail.bind(this), + this.handleServiceErrors + ); + router.get( + '/:id/mail', + [...this.paymentReceiveValidation], + asyncMiddleware(this.getPaymentDefaultMail.bind(this)), + this.handleServiceErrors + ); return router; } @@ -328,17 +349,12 @@ export default class PaymentReceivesController extends BaseController { }; try { - const { paymentReceives, pagination, filterMeta } = + const paymentsReceivedWithPagination = await this.paymentReceiveApplication.getPaymentReceives( tenantId, filter ); - - return res.status(200).send({ - payment_receives: this.transfromToResponse(paymentReceives), - pagination: this.transfromToResponse(pagination), - filter_meta: this.transfromToResponse(filterMeta), - }); + return res.status(200).send(paymentsReceivedWithPagination); } catch (error) { next(error); } @@ -415,38 +431,34 @@ export default class PaymentReceivesController extends BaseController { const { tenantId } = req; const { id: paymentReceiveId } = req.params; - try { + const accept = this.accepts(req); + + const acceptType = accept.types([ + ACCEPT_TYPE.APPLICATION_JSON, + ACCEPT_TYPE.APPLICATION_PDF, + ]); + // Response in pdf format. + if (ACCEPT_TYPE.APPLICATION_PDF === acceptType) { + const pdfContent = + await this.paymentReceiveApplication.getPaymentReceivePdf( + tenantId, + paymentReceiveId + ); + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, + }); + res.send(pdfContent); + // Response in json format. + } else { const paymentReceive = await this.paymentReceiveApplication.getPaymentReceive( tenantId, paymentReceiveId ); - - const ACCEPT_TYPE = { - APPLICATION_PDF: 'application/pdf', - APPLICATION_JSON: 'application/json', - }; - res.format({ - [ACCEPT_TYPE.APPLICATION_JSON]: () => { - return res.status(200).send({ - payment_receive: this.transfromToResponse(paymentReceive), - }); - }, - [ACCEPT_TYPE.APPLICATION_PDF]: async () => { - const pdfContent = - await this.paymentReceiveApplication.getPaymentReceivePdf( - tenantId, - paymentReceive - ); - res.set({ - 'Content-Type': 'application/pdf', - 'Content-Length': pdfContent.length, - }); - res.send(pdfContent); - }, + return res.status(200).send({ + payment_receive: paymentReceive, }); - } catch (error) { - next(error); } } @@ -480,7 +492,7 @@ export default class PaymentReceivesController extends BaseController { }; /** - * + * Retrieves the sms details of the given payment receive. * @param {Request} req * @param {Response} res * @param {NextFunction} next @@ -508,13 +520,73 @@ export default class PaymentReceivesController extends BaseController { }; /** - * Handles service errors. - * @param error - * @param req - * @param res - * @param next + * Sends mail invoice of the given sale invoice. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next */ - handleServiceErrors( + public sendPaymentReceiveByMail = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: paymentReceiveId } = req.params; + const paymentMailDTO: PaymentReceiveMailOptsDTO = this.matchedBodyData( + req, + { + includeOptionals: false, + } + ); + + try { + await this.paymentReceiveApplication.notifyPaymentByMail( + tenantId, + paymentReceiveId, + paymentMailDTO + ); + return res.status(200).send({ + code: 200, + message: 'The payment notification has been sent successfully.', + }); + } catch (error) { + next(error); + } + }; + + /** + * Retrieves the default mail options of the given payment transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public getPaymentDefaultMail = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: paymentReceiveId } = req.params; + + try { + const data = await this.paymentReceiveApplication.getPaymentMailOptions( + tenantId, + paymentReceiveId + ); + return res.status(200).send({ data }); + } catch (error) { + next(error); + } + }; + + /** + * Handles service errors. + * @param {Error} error + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private handleServiceErrors( error: Error, req: Request, res: Response, diff --git a/packages/server/src/api/controllers/Sales/SalesEstimates.ts b/packages/server/src/api/controllers/Sales/SalesEstimates.ts index 9f3cf3719..e26b40c30 100644 --- a/packages/server/src/api/controllers/Sales/SalesEstimates.ts +++ b/packages/server/src/api/controllers/Sales/SalesEstimates.ts @@ -1,10 +1,11 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { check, param, query } from 'express-validator'; +import { body, check, param, query } from 'express-validator'; import { Inject, Service } from 'typedi'; import { AbilitySubject, ISaleEstimateDTO, SaleEstimateAction, + SaleEstimateMailOptionsDTO, } from '@/interfaces'; import BaseController from '@/api/controllers/BaseController'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; @@ -121,6 +122,27 @@ export default class SalesEstimatesController extends BaseController { this.handleServiceErrors, this.dynamicListService.handlerErrorsToResponse ); + router.post( + '/:id/mail', + [ + ...this.validateSpecificEstimateSchema, + body('subject').isString().optional(), + body('from').isString().optional(), + body('to').isString().optional(), + body('body').isString().optional(), + body('attach_invoice').optional().isBoolean().toBoolean(), + ], + this.validationResult, + asyncMiddleware(this.sendSaleEstimateMail.bind(this)), + this.handleServiceErrors + ); + router.get( + '/:id/mail', + [...this.validateSpecificEstimateSchema], + this.validationResult, + asyncMiddleware(this.getSaleEstimateMail.bind(this)), + this.handleServiceErrors + ); return router; } @@ -312,7 +334,6 @@ export default class SalesEstimatesController extends BaseController { tenantId, estimateId ); - return res.status(200).send({ id: estimateId, message: 'The sale estimate has been approved successfully.', @@ -341,7 +362,6 @@ export default class SalesEstimatesController extends BaseController { tenantId, estimateId ); - return res.status(200).send({ id: estimateId, message: 'The sale estimate has been rejected successfully.', @@ -361,33 +381,30 @@ export default class SalesEstimatesController extends BaseController { const { id: estimateId } = req.params; const { tenantId } = req; - try { + const accept = this.accepts(req); + + const acceptType = accept.types([ + ACCEPT_TYPE.APPLICATION_JSON, + ACCEPT_TYPE.APPLICATION_PDF, + ]); + // Retrieves estimate in pdf format. + if (ACCEPT_TYPE.APPLICATION_PDF == acceptType) { + const pdfContent = await this.saleEstimatesApplication.getSaleEstimatePdf( + tenantId, + estimateId + ); + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, + }); + res.send(pdfContent); + // Retrieves estimates in json format. + } else { const estimate = await this.saleEstimatesApplication.getSaleEstimate( tenantId, estimateId ); - // Response formatter. - res.format({ - // JSON content type. - [ACCEPT_TYPE.APPLICATION_JSON]: () => { - return res.status(200).send(this.transfromToResponse({ estimate })); - }, - // PDF content type. - [ACCEPT_TYPE.APPLICATION_PDF]: async () => { - const pdfContent = - await this.saleEstimatesApplication.getSaleEstimatePdf( - tenantId, - estimate - ); - res.set({ - 'Content-Type': 'application/pdf', - 'Content-Length': pdfContent.length, - }); - res.send(pdfContent); - }, - }); - } catch (error) { - next(error); + return res.status(200).send({ estimate }); } } @@ -405,22 +422,11 @@ export default class SalesEstimatesController extends BaseController { pageSize: 12, ...this.matchedQueryData(req), }; - try { - const { salesEstimates, pagination, filterMeta } = + const salesEstimatesWithPagination = await this.saleEstimatesApplication.getSaleEstimates(tenantId, filter); - res.format({ - [ACCEPT_TYPE.APPLICATION_JSON]: () => { - return res.status(200).send( - this.transfromToResponse({ - salesEstimates, - pagination, - filterMeta, - }) - ); - }, - }); + return res.status(200).send(salesEstimatesWithPagination); } catch (error) { next(error); } @@ -478,6 +484,65 @@ export default class SalesEstimatesController extends BaseController { } }; + /** + * Send the sale estimate mail. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private sendSaleEstimateMail = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: invoiceId } = req.params; + const saleEstimateDTO: SaleEstimateMailOptionsDTO = this.matchedBodyData( + req, + { + includeOptionals: false, + } + ); + try { + await this.saleEstimatesApplication.sendSaleEstimateMail( + tenantId, + invoiceId, + saleEstimateDTO + ); + return res.status(200).send({ + code: 200, + message: 'The sale estimate mail has been sent successfully.', + }); + } catch (error) { + next(error); + } + }; + + /** + * Retrieves the default mail options of the given sale estimate. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private getSaleEstimateMail = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: invoiceId } = req.params; + + try { + const data = await this.saleEstimatesApplication.getSaleEstimateMail( + tenantId, + invoiceId + ); + return res.status(200).send({ data }); + } catch (error) { + next(error); + } + }; + /** * Handles service errors. * @param {Error} error diff --git a/packages/server/src/api/controllers/Sales/SalesInvoices.ts b/packages/server/src/api/controllers/Sales/SalesInvoices.ts index d90b94d8d..b45604e43 100644 --- a/packages/server/src/api/controllers/Sales/SalesInvoices.ts +++ b/packages/server/src/api/controllers/Sales/SalesInvoices.ts @@ -1,5 +1,5 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { check, param, query } from 'express-validator'; +import { body, check, param, query } from 'express-validator'; import { Service, Inject } from 'typedi'; import BaseController from '../BaseController'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; @@ -10,14 +10,12 @@ import { ISaleInvoiceCreateDTO, SaleInvoiceAction, AbilitySubject, + SendInvoiceMailDTO, } from '@/interfaces'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { SaleInvoiceApplication } from '@/services/Sales/Invoices/SaleInvoicesApplication'; +import { ACCEPT_TYPE } from '@/interfaces/Http'; -const ACCEPT_TYPE = { - APPLICATION_PDF: 'application/pdf', - APPLICATION_JSON: 'application/json', -}; @Service() export default class SaleInvoicesController extends BaseController { @Inject() @@ -145,6 +143,48 @@ export default class SaleInvoicesController extends BaseController { this.handleServiceErrors, this.dynamicListService.handlerErrorsToResponse ); + router.get( + '/:id/mail-reminder', + this.specificSaleInvoiceValidation, + this.validationResult, + asyncMiddleware(this.getSaleInvoiceMailReminder.bind(this)), + this.handleServiceErrors + ); + router.post( + '/:id/mail-reminder', + [ + ...this.specificSaleInvoiceValidation, + body('subject').isString().optional(), + body('from').isString().optional(), + body('to').isString().optional(), + body('body').isString().optional(), + body('attach_invoice').optional().isBoolean().toBoolean(), + ], + this.validationResult, + asyncMiddleware(this.sendSaleInvoiceMailReminder.bind(this)), + this.handleServiceErrors + ); + router.post( + '/:id/mail', + [ + ...this.specificSaleInvoiceValidation, + body('subject').isString().optional(), + body('from').isString().optional(), + body('to').isString().optional(), + body('body').isString().optional(), + body('attach_invoice').optional().isBoolean().toBoolean(), + ], + this.validationResult, + asyncMiddleware(this.sendSaleInvoiceMail.bind(this)), + this.handleServiceErrors + ); + router.get( + '/:id/mail', + [...this.specificSaleInvoiceValidation], + this.validationResult, + asyncMiddleware(this.getSaleInvoiceMail.bind(this)), + this.handleServiceErrors + ); return router; } @@ -360,7 +400,6 @@ export default class SaleInvoicesController extends BaseController { saleInvoiceId, user ); - return res.status(200).send({ id: saleInvoiceId, message: 'The sale invoice has been deleted successfully.', @@ -375,43 +414,35 @@ export default class SaleInvoicesController extends BaseController { * @param {Request} req - Request object. * @param {Response} res - Response object. */ - private async getSaleInvoice( - req: Request, - res: Response, - next: NextFunction - ) { + private async getSaleInvoice(req: Request, res: Response) { const { id: saleInvoiceId } = req.params; const { tenantId, user } = req; - try { + const accept = this.accepts(req); + + const acceptType = accept.types([ + ACCEPT_TYPE.APPLICATION_JSON, + ACCEPT_TYPE.APPLICATION_PDF, + ]); + // Retrieves invoice in pdf format. + if (ACCEPT_TYPE.APPLICATION_PDF == acceptType) { + const pdfContent = await this.saleInvoiceApplication.saleInvoicePdf( + tenantId, + saleInvoiceId + ); + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, + }); + res.send(pdfContent); + // Retrieves invoice in json format. + } else { const saleInvoice = await this.saleInvoiceApplication.getSaleInvoice( tenantId, saleInvoiceId, user ); - // Response formatter. - res.format({ - // JSON content type. - [ACCEPT_TYPE.APPLICATION_JSON]: () => { - return res - .status(200) - .send(this.transfromToResponse({ saleInvoice })); - }, - // PDF content type. - [ACCEPT_TYPE.APPLICATION_PDF]: async () => { - const pdfContent = await this.saleInvoiceApplication.saleInvoicePdf( - tenantId, - saleInvoice - ); - res.set({ - 'Content-Type': 'application/pdf', - 'Content-Length': pdfContent.length, - }); - res.send(pdfContent); - }, - }); - } catch (error) { - next(error); + return res.status(200).send({ saleInvoice }); } } /** @@ -434,14 +465,10 @@ export default class SaleInvoicesController extends BaseController { ...this.matchedQueryData(req), }; try { - const { salesInvoices, filterMeta, pagination } = + const salesInvoicesWithPagination = await this.saleInvoiceApplication.getSaleInvoices(tenantId, filter); - return res.status(200).send({ - sales_invoices: this.transfromToResponse(salesInvoices), - pagination: this.transfromToResponse(pagination), - filter_meta: this.transfromToResponse(filterMeta), - }); + return res.status(200).send(salesInvoicesWithPagination); } catch (error) { next(error); } @@ -468,9 +495,7 @@ export default class SaleInvoicesController extends BaseController { tenantId, customerId ); - return res.status(200).send({ - sales_invoices: this.transfromToResponse(salesInvoices), - }); + return res.status(200).send({ salesInvoices }); } catch (error) { next(error); } @@ -498,7 +523,6 @@ export default class SaleInvoicesController extends BaseController { invoiceId, writeoffDTO ); - return res.status(200).send({ id: saleInvoice.id, message: 'The given sale invoice has been written-off successfully.', @@ -630,6 +654,119 @@ export default class SaleInvoicesController extends BaseController { } }; + /** + * Sends mail invoice of the given sale invoice. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public async sendSaleInvoiceMail( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { id: invoiceId } = req.params; + const invoiceMailDTO: SendInvoiceMailDTO = this.matchedBodyData(req, { + includeOptionals: false, + }); + + try { + await this.saleInvoiceApplication.sendSaleInvoiceMail( + tenantId, + invoiceId, + invoiceMailDTO + ); + return res.status(200).send({ + code: 200, + message: 'The sale invoice mail has been sent successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Retreivers the sale invoice reminder options. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public async getSaleInvoiceMailReminder( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { id: invoiceId } = req.params; + + try { + const data = await this.saleInvoiceApplication.getSaleInvoiceMailReminder( + tenantId, + invoiceId + ); + return res.status(200).send(data); + } catch (error) { + next(error); + } + } + + /** + * Sends mail invoice of the given sale invoice. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public async sendSaleInvoiceMailReminder( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { id: invoiceId } = req.params; + const invoiceMailDTO: SendInvoiceMailDTO = this.matchedBodyData(req, { + includeOptionals: false, + }); + try { + await this.saleInvoiceApplication.sendSaleInvoiceMailReminder( + tenantId, + invoiceId, + invoiceMailDTO + ); + return res.status(200).send({ + code: 200, + message: 'The sale invoice mail reminder has been sent successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Retrieves the default mail options of the given sale invoice. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public async getSaleInvoiceMail( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { id: invoiceId } = req.params; + + try { + const data = await this.saleInvoiceApplication.getSaleInvoiceMail( + tenantId, + invoiceId + ); + return res.status(200).send({ data }); + } catch (error) { + next(error); + } + } + /** * Handles service errors. * @param {Error} error diff --git a/packages/server/src/api/controllers/Sales/SalesReceipts.ts b/packages/server/src/api/controllers/Sales/SalesReceipts.ts index 3eabcf84e..04edfd9f8 100644 --- a/packages/server/src/api/controllers/Sales/SalesReceipts.ts +++ b/packages/server/src/api/controllers/Sales/SalesReceipts.ts @@ -1,14 +1,19 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { check, param, query } from 'express-validator'; +import { body, check, param, query } from 'express-validator'; import { Inject, Service } from 'typedi'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import BaseController from '../BaseController'; -import { ISaleReceiptDTO } from '@/interfaces/SaleReceipt'; +import { + ISaleReceiptDTO, + SaleReceiptMailOpts, + SaleReceiptMailOptsDTO, +} from '@/interfaces/SaleReceipt'; import { ServiceError } from '@/exceptions'; import DynamicListingService from '@/services/DynamicListing/DynamicListService'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, SaleReceiptAction } from '@/interfaces'; import { SaleReceiptApplication } from '@/services/Sales/Receipts/SaleReceiptApplication'; +import { ACCEPT_TYPE } from '@/interfaces/Http'; @Service() export default class SalesReceiptsController extends BaseController { @@ -46,6 +51,27 @@ export default class SalesReceiptsController extends BaseController { this.saleReceiptSmsDetails, this.handleServiceErrors ); + router.post( + '/:id/mail', + [ + ...this.specificReceiptValidationSchema, + body('subject').isString().optional(), + body('from').isString().optional(), + body('to').isString().optional(), + body('body').isString().optional(), + body('attach_receipt').optional().isBoolean().toBoolean(), + ], + this.validationResult, + asyncMiddleware(this.sendSaleReceiptMail.bind(this)), + this.handleServiceErrors + ); + router.get( + '/:id/mail', + [...this.specificReceiptValidationSchema], + this.validationResult, + asyncMiddleware(this.getSaleReceiptMail.bind(this)), + this.handleServiceErrors + ); router.post( '/:id', CheckPolicies(SaleReceiptAction.Edit, AbilitySubject.SaleReceipt), @@ -205,7 +231,6 @@ export default class SalesReceiptsController extends BaseController { tenantId, saleReceiptId ); - return res.status(200).send({ id: saleReceiptId, message: 'Sale receipt has been deleted successfully.', @@ -294,15 +319,10 @@ export default class SalesReceiptsController extends BaseController { ...this.matchedQueryData(req), }; try { - const { data, pagination, filterMeta } = + const salesReceiptsWithPagination = await this.saleReceiptsApplication.getSaleReceipts(tenantId, filter); - const response = this.transfromToResponse({ - data, - pagination, - filterMeta, - }); - return res.status(200).send(response); + return res.status(200).send(salesReceiptsWithPagination); } catch (error) { next(error); } @@ -314,36 +334,34 @@ export default class SalesReceiptsController extends BaseController { * @param {Response} res * @param {NextFunction} next */ - async getSaleReceipt(req: Request, res: Response, next: NextFunction) { + public async getSaleReceipt(req: Request, res: Response) { const { id: saleReceiptId } = req.params; const { tenantId } = req; - try { + const accept = this.accepts(req); + + const acceptType = accept.types([ + ACCEPT_TYPE.APPLICATION_JSON, + ACCEPT_TYPE.APPLICATION_PDF, + ]); + // Retrieves receipt in pdf format. + if (ACCEPT_TYPE.APPLICATION_PDF == acceptType) { + const pdfContent = await this.saleReceiptsApplication.getSaleReceiptPdf( + tenantId, + saleReceiptId + ); + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, + }); + res.send(pdfContent); + // Retrieves receipt in json format. + } else { const saleReceipt = await this.saleReceiptsApplication.getSaleReceipt( tenantId, saleReceiptId ); - res.format({ - 'application/json': () => { - return res - .status(200) - .send(this.transfromToResponse({ saleReceipt })); - }, - 'application/pdf': async () => { - const pdfContent = - await this.saleReceiptsApplication.getSaleReceiptPdf( - tenantId, - saleReceipt - ); - res.set({ - 'Content-Type': 'application/pdf', - 'Content-Length': pdfContent.length, - }); - res.send(pdfContent); - }, - }); - } catch (error) { - next(error); + return res.status(200).send({ saleReceipt }); } } @@ -405,6 +423,64 @@ export default class SalesReceiptsController extends BaseController { } }; + /** + * Sends mail notification of the given sale receipt. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public sendSaleReceiptMail = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: receiptId } = req.params; + const receiptMailDTO: SaleReceiptMailOptsDTO = this.matchedBodyData(req, { + includeOptionals: false, + }); + + try { + await this.saleReceiptsApplication.sendSaleReceiptMail( + tenantId, + receiptId, + receiptMailDTO + ); + return res.status(200).send({ + code: 200, + message: + 'The sale receipt notification via sms has been sent successfully.', + }); + } catch (error) { + next(error); + } + }; + + /** + * Retrieves the default mail options of the given sale receipt. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public getSaleReceiptMail = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: receiptId } = req.params; + + try { + const data = await this.saleReceiptsApplication.getSaleReceiptMail( + tenantId, + receiptId + ); + return res.status(200).send({ data }); + } catch (error) { + next(error); + } + }; + /** * Handles service errors. * @param {Error} error diff --git a/packages/server/src/api/controllers/Webhooks/Webhooks.ts b/packages/server/src/api/controllers/Webhooks/Webhooks.ts new file mode 100644 index 000000000..955e382fb --- /dev/null +++ b/packages/server/src/api/controllers/Webhooks/Webhooks.ts @@ -0,0 +1,47 @@ +import { Router } from 'express'; +import { PlaidApplication } from '@/services/Banking/Plaid/PlaidApplication'; +import { Request, Response } from 'express'; +import { Inject, Service } from 'typedi'; +import BaseController from '../BaseController'; +import { PlaidWebhookTenantBootMiddleware } from '@/services/Banking/Plaid/PlaidWebhookTenantBootMiddleware'; + +@Service() +export class Webhooks extends BaseController { + @Inject() + private plaidApp: PlaidApplication; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.use(PlaidWebhookTenantBootMiddleware); + router.post('/plaid', this.plaidWebhooks.bind(this)); + + return router; + } + + /** + * Listens to Plaid webhooks. + * @param {Request} req + * @param {Response} res + * @returns {Response} + */ + public async plaidWebhooks(req: Request, res: Response) { + const { tenantId } = req; + const { + webhook_type: webhookType, + webhook_code: webhookCode, + item_id: plaidItemId, + } = req.body; + + await this.plaidApp.webhooks( + tenantId, + plaidItemId, + webhookType, + webhookCode + ); + return res.status(200).send({ code: 200, message: 'ok' }); + } +} diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts index 1a3ff17c6..cc1206aee 100644 --- a/packages/server/src/api/index.ts +++ b/packages/server/src/api/index.ts @@ -57,6 +57,8 @@ import { ProjectTasksController } from './controllers/Projects/Tasks'; import { ProjectTimesController } from './controllers/Projects/Times'; import { TaxRatesController } from './controllers/TaxRates/TaxRates'; import { ImportController } from './controllers/Import/ImportController'; +import { BankingController } from './controllers/Banking/BankingController'; +import { Webhooks } from './controllers/Webhooks/Webhooks'; export default () => { const app = Router(); @@ -72,6 +74,7 @@ export default () => { app.use('/ping', Container.get(Ping).router()); app.use('/jobs', Container.get(Jobs).router()); app.use('/account', Container.get(Account).router()); + app.use('/webhooks', Container.get(Webhooks).router()); // - Dashboard routes. // --------------------------- @@ -119,6 +122,7 @@ export default () => { Container.get(InventoryItemsCostController).router() ); dashboard.use('/cashflow', Container.get(CashflowController).router()); + dashboard.use('/banking', Container.get(BankingController).router()); dashboard.use('/roles', Container.get(RolesController).router()); dashboard.use( '/transactions-locking', diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index bc6833130..12938038f 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -58,6 +58,7 @@ module.exports = { secure: !!parseInt(process.env.MAIL_SECURE, 10), username: process.env.MAIL_USERNAME, password: process.env.MAIL_PASSWORD, + from: process.env.MAIL_FROM_ADDRESS, }, /** @@ -168,4 +169,27 @@ module.exports = { * to application detarmines to upgrade. */ databaseBatch: 4, + + /** + * Exchange rate. + */ + exchangeRate: { + service: 'open-exchange-rate', + openExchangeRate: { + appId: process.env.OPEN_EXCHANGE_RATE_APP_ID, + }, + }, + + /** + * Plaid. + */ + plaid: { + env: process.env.PLAID_ENV || 'sandbox', + clientId: process.env.PLAID_CLIENT_ID, + secretDevelopment: process.env.PLAID_SECRET_DEVELOPMENT, + secretSandbox: process.env.PLAID_SECRET_SANDBOX, + redirectSandBox: process.env.PLAID_SANDBOX_REDIRECT_URI, + redirectDevelopment: process.env.PLAID_DEVELOPMENT_REDIRECT_URI, + linkWebhook: process.env.PLAID_LINK_WEBHOOK + }, }; diff --git a/packages/server/src/data/options.ts b/packages/server/src/data/options.ts index 023628ef8..2c0377fda 100644 --- a/packages/server/src/data/options.ts +++ b/packages/server/src/data/options.ts @@ -59,6 +59,12 @@ export default { auto_increment: { type: 'boolean', }, + customer_notes: { + type: 'string', + }, + terms_conditions: { + type: 'string', + }, }, sales_receipts: { next_number: { @@ -73,6 +79,12 @@ export default { preferred_deposit_account: { type: 'number', }, + receipt_message: { + type: 'string', + }, + terms_conditions: { + type: 'string', + }, }, sales_invoices: { next_number: { @@ -84,6 +96,12 @@ export default { auto_increment: { type: 'boolean', }, + customer_notes: { + type: 'string', + }, + terms_conditions: { + type: 'string', + }, }, payment_receives: { next_number: { @@ -118,6 +136,11 @@ export default { type: 'number', }, }, + inventory: { + cost_compute_running: { + type: 'boolean', + }, + }, accounts: { account_code_required: { type: 'boolean', @@ -147,6 +170,12 @@ export default { auto_increment: { type: 'boolean', }, + customer_notes: { + type: 'string', + }, + terms_conditions: { + type: 'string', + }, }, vendor_credit: { next_number: { diff --git a/packages/server/src/database/migrations/20240201160214_create_plaid_items_Table.js b/packages/server/src/database/migrations/20240201160214_create_plaid_items_Table.js new file mode 100644 index 000000000..089283290 --- /dev/null +++ b/packages/server/src/database/migrations/20240201160214_create_plaid_items_Table.js @@ -0,0 +1,14 @@ +exports.up = function (knex) { + return knex.schema.createTable('plaid_items', (table) => { + table.increments('id'); + table.integer('tenant_id').unsigned(); + table.string('plaid_item_id'); + table.string('plaid_institution_id'); + table.string('plaid_access_token'); + table.string('last_cursor'); + table.string('status'); + table.timestamps(); + }); +}; + +exports.down = function (knex) {}; diff --git a/packages/server/src/database/migrations/20240201235818_add_plaid_account_id_to_accounts_table.js b/packages/server/src/database/migrations/20240201235818_add_plaid_account_id_to_accounts_table.js new file mode 100644 index 000000000..901f08987 --- /dev/null +++ b/packages/server/src/database/migrations/20240201235818_add_plaid_account_id_to_accounts_table.js @@ -0,0 +1,9 @@ +exports.up = function (knex) { + return knex.schema.table('accounts', (table) => { + table.string('plaid_account_id'); + table.string('account_mask').nullable(); + table.decimal('bank_balance', 15, 5); + }); +}; + +exports.down = function (knex) {}; diff --git a/packages/server/src/database/migrations/20240204180554_add_plaid_transaction_id_to_cashflow_transaction.js b/packages/server/src/database/migrations/20240204180554_add_plaid_transaction_id_to_cashflow_transaction.js new file mode 100644 index 000000000..3c88cd589 --- /dev/null +++ b/packages/server/src/database/migrations/20240204180554_add_plaid_transaction_id_to_cashflow_transaction.js @@ -0,0 +1,7 @@ +exports.up = function (knex) { + return knex.schema.table('cashflow_transactions', (table) => { + table.string('plaid_transaction_id'); + }); +}; + +exports.down = function (knex) {}; diff --git a/packages/server/src/database/migrations/20240228183404_create_uncateogrized_cashflow_transactions_table.js b/packages/server/src/database/migrations/20240228183404_create_uncateogrized_cashflow_transactions_table.js new file mode 100644 index 000000000..29a596772 --- /dev/null +++ b/packages/server/src/database/migrations/20240228183404_create_uncateogrized_cashflow_transactions_table.js @@ -0,0 +1,28 @@ +exports.up = function (knex) { + return knex.schema.createTable( + 'uncategorized_cashflow_transactions', + (table) => { + table.increments('id'); + table.date('date').index(); + table.decimal('amount'); + table.string('currency_code'); + table.string('reference_no').index(); + table.string('payee'); + table + .integer('account_id') + .unsigned() + .references('id') + .inTable('accounts'); + table.string('description'); + table.string('categorize_ref_type'); + table.integer('categorize_ref_id').unsigned(); + table.boolean('categorized').defaultTo(false); + table.string('plaid_transaction_id'); + table.timestamps(); + } + ); +}; + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('uncategorized_cashflow_transactions'); +}; diff --git a/packages/server/src/database/migrations/20240304153926_add_uncategorized_transactions_column_to_accounts_table.js b/packages/server/src/database/migrations/20240304153926_add_uncategorized_transactions_column_to_accounts_table.js new file mode 100644 index 000000000..06d05f521 --- /dev/null +++ b/packages/server/src/database/migrations/20240304153926_add_uncategorized_transactions_column_to_accounts_table.js @@ -0,0 +1,10 @@ +exports.up = function (knex) { + return knex.schema.table('accounts', (table) => { + table.integer('uncategorized_transactions').defaultTo(0); + table.boolean('is_system_account').defaultTo(true); + table.boolean('is_feeds_active').defaultTo(false); + table.datetime('last_feeds_updated_at').nullable(); + }); +}; + +exports.down = function (knex) {}; diff --git a/packages/server/src/database/migrations/20240308122047_add_uncategorized_transaction_id_to_cashflow_transactions.js b/packages/server/src/database/migrations/20240308122047_add_uncategorized_transaction_id_to_cashflow_transactions.js new file mode 100644 index 000000000..01b93bea5 --- /dev/null +++ b/packages/server/src/database/migrations/20240308122047_add_uncategorized_transaction_id_to_cashflow_transactions.js @@ -0,0 +1,15 @@ +exports.up = function (knex) { + return knex.schema.table('cashflow_transactions', (table) => { + table + .integer('uncategorized_transaction_id') + .unsigned() + .references('id') + .inTable('uncategorized_cashflow_transactions'); + }); +}; + +exports.down = function (knex) { + return knex.schema.table('cashflow_transactions', (table) => { + table.dropColumn('uncategorized_transaction_id'); + }); +}; diff --git a/packages/server/src/interfaces/APAgingSummaryReport.ts b/packages/server/src/interfaces/APAgingSummaryReport.ts index 6bf7c83bb..21ed036c7 100644 --- a/packages/server/src/interfaces/APAgingSummaryReport.ts +++ b/packages/server/src/interfaces/APAgingSummaryReport.ts @@ -1,13 +1,11 @@ import { IAgingPeriod, - IAgingPeriodTotal, - IAgingAmount, IAgingSummaryQuery, IAgingSummaryTotal, IAgingSummaryContact, IAgingSummaryData, } from './AgingReport'; -import { INumberFormatQuery } from './FinancialStatements'; +import { IFinancialSheetCommonMeta } from './FinancialStatements'; import { IFinancialTable } from './Table'; export interface IAPAgingSummaryQuery extends IAgingSummaryQuery { @@ -26,17 +24,22 @@ export interface IAPAgingSummaryData extends IAgingSummaryData { export type IAPAgingSummaryColumns = IAgingPeriod[]; -export interface IARAgingSummaryMeta { - baseCurrency: string; - organizationName: string; +export interface IARAgingSummaryMeta extends IFinancialSheetCommonMeta { + formattedAsDate: string; } -export interface IAPAgingSummaryMeta { - baseCurrency: string; - organizationName: string; +export interface IAPAgingSummaryMeta extends IFinancialSheetCommonMeta { + formattedAsDate: string; } export interface IAPAgingSummaryTable extends IFinancialTable { query: IAPAgingSummaryQuery; meta: IAPAgingSummaryMeta; } + +export interface IAPAgingSummarySheet { + data: IAPAgingSummaryData; + meta: IAPAgingSummaryMeta; + query: IAPAgingSummaryQuery; + columns: any; +} diff --git a/packages/server/src/interfaces/ARAgingSummaryReport.ts b/packages/server/src/interfaces/ARAgingSummaryReport.ts index d42fc8f7f..b20753927 100644 --- a/packages/server/src/interfaces/ARAgingSummaryReport.ts +++ b/packages/server/src/interfaces/ARAgingSummaryReport.ts @@ -32,3 +32,11 @@ export interface IARAgingSummaryTable extends IFinancialTable { meta: IARAgingSummaryMeta; query: IARAgingSummaryQuery; } + +export interface IARAgingSummarySheet { + data: IARAgingSummaryData; + meta: IARAgingSummaryMeta; + query: IARAgingSummaryQuery; + columns: IARAgingSummaryColumns; +} + diff --git a/packages/server/src/interfaces/Account.ts b/packages/server/src/interfaces/Account.ts index 2239448c1..7c045def3 100644 --- a/packages/server/src/interfaces/Account.ts +++ b/packages/server/src/interfaces/Account.ts @@ -6,12 +6,15 @@ export interface IAccountDTO { code: string; description: string; accountType: string; - parentAccountId: number; + parentAccountId?: number; active: boolean; + bankBalance?: number; + accountMask?: string; } export interface IAccountCreateDTO extends IAccountDTO { currencyCode?: string; + plaidAccountId?: string; } export interface IAccountEditDTO extends IAccountDTO {} @@ -33,6 +36,7 @@ export interface IAccount { type?: any[]; accountNormal: string; accountParentType: string; + bankBalance: string; } export enum AccountNormal { @@ -154,10 +158,9 @@ export enum AccountAction { TransactionsLocking = 'TransactionsLocking', } - export enum TaxRateAction { CREATE = 'Create', EDIT = 'Edit', DELETE = 'Delete', VIEW = 'View', -} \ No newline at end of file +} diff --git a/packages/server/src/interfaces/AgingReport.ts b/packages/server/src/interfaces/AgingReport.ts index c68b6b389..dc2a0dd1b 100644 --- a/packages/server/src/interfaces/AgingReport.ts +++ b/packages/server/src/interfaces/AgingReport.ts @@ -1,5 +1,7 @@ - -import { INumberFormatQuery } from './FinancialStatements'; +import { + IFinancialSheetCommonMeta, + INumberFormatQuery, +} from './FinancialStatements'; export interface IAgingPeriodTotal extends IAgingPeriod { total: IAgingAmount; @@ -42,3 +44,8 @@ export interface IAgingSummaryTotal { export interface IAgingSummaryData { total: IAgingSummaryTotal; } + +export interface IAgingSummaryMeta extends IFinancialSheetCommonMeta { + formattedAsDate: string; + formattedDateRange: string; +} diff --git a/packages/server/src/interfaces/BalanceSheet.ts b/packages/server/src/interfaces/BalanceSheet.ts index a74e48ee8..567cb8764 100644 --- a/packages/server/src/interfaces/BalanceSheet.ts +++ b/packages/server/src/interfaces/BalanceSheet.ts @@ -2,6 +2,7 @@ import { INumberFormatQuery, IFormatNumberSettings, IFinancialSheetBranchesQuery, + IFinancialSheetCommonMeta, } from './FinancialStatements'; import { IFinancialTable } from './Table'; @@ -63,10 +64,9 @@ export interface IBalanceSheetQuery extends IFinancialSheetBranchesQuery { } // Balance sheet meta. -export interface IBalanceSheetMeta { - isCostComputeRunning: boolean; - organizationName: string; - baseCurrency: string; +export interface IBalanceSheetMeta extends IFinancialSheetCommonMeta { + formattedAsDate: string; + formattedDateRange: string; } export interface IBalanceSheetFormatNumberSettings diff --git a/packages/server/src/interfaces/CashFlow.ts b/packages/server/src/interfaces/CashFlow.ts index 1a4a1a6a1..499c526b0 100644 --- a/packages/server/src/interfaces/CashFlow.ts +++ b/packages/server/src/interfaces/CashFlow.ts @@ -1,4 +1,7 @@ -import { INumberFormatQuery } from './FinancialStatements'; +import { + IFinancialSheetCommonMeta, + INumberFormatQuery, +} from './FinancialStatements'; import { IAccount } from './Account'; import { ILedger } from './Ledger'; import { IFinancialTable, ITableRow } from './Table'; @@ -79,8 +82,8 @@ export interface ICashFlowStatementAggregateSection export interface ICashFlowCashBeginningNode extends ICashFlowStatementCommonSection { - sectionType: ICashFlowStatementSectionType.CASH_AT_BEGINNING; - } + sectionType: ICashFlowStatementSectionType.CASH_AT_BEGINNING; +} export type ICashFlowStatementSection = | ICashFlowStatementAccountSection @@ -89,10 +92,10 @@ export type ICashFlowStatementSection = | ICashFlowStatementCommonSection; export interface ICashFlowStatementColumn {} -export interface ICashFlowStatementMeta { - isCostComputeRunning: boolean; - organizationName: string; - baseCurrency: string; +export interface ICashFlowStatementMeta extends IFinancialSheetCommonMeta { + formattedToDate: string; + formattedFromDate: string; + formattedDateRange: string; } export interface ICashFlowStatementDOO { @@ -230,3 +233,38 @@ export interface ICashflowTransactionSchema { } export interface ICashflowTransactionInput extends ICashflowTransactionSchema {} + +export interface ICategorizeCashflowTransactioDTO { + creditAccountId: number; + referenceNo: string; + transactionNumber: string; + transactionType: string; + exchangeRate: number; + description: string; + branchId: number; +} + +export interface IUncategorizedCashflowTransaction { + id?: number; + amount: number; + date: Date; + currencyCode: string; + accountId: number; + description: string; + referenceNo: string; + categorizeRefType: string; + categorizeRefId: number; + categorized: boolean; +} + + +export interface CreateUncategorizedTransactionDTO { + date: Date | string; + accountId: number; + amount: number; + currencyCode: string; + payee?: string; + description?: string; + referenceNo?: string | null; + plaidTransactionId?: string | null; +} diff --git a/packages/server/src/interfaces/CashflowService.ts b/packages/server/src/interfaces/CashflowService.ts index 8b0576d8e..7d427b998 100644 --- a/packages/server/src/interfaces/CashflowService.ts +++ b/packages/server/src/interfaces/CashflowService.ts @@ -1,5 +1,6 @@ import { Knex } from 'knex'; import { IAccount } from './Account'; +import { IUncategorizedCashflowTransaction } from './CashFlow'; export interface ICashflowAccountTransactionsFilter { page: number; @@ -45,9 +46,13 @@ export interface ICashflowCommandDTO { publish: boolean; branchId?: number; + plaidTransactionId?: string; } -export interface ICashflowNewCommandDTO extends ICashflowCommandDTO {} +export interface ICashflowNewCommandDTO extends ICashflowCommandDTO { + plaidAccountId?: string; + uncategorizedTransactionId?: number; +} export interface ICashflowTransaction { id?: number; @@ -79,6 +84,8 @@ export interface ICashflowTransaction { isCashDebit?: boolean; isCashCredit?: boolean; + + uncategorizedTransactionId?: number; } export interface ICashflowTransactionLine { @@ -121,8 +128,39 @@ export interface ICommandCashflowDeletedPayload { trx: Knex.Transaction; } +export interface ICashflowTransactionCategorizedPayload { + tenantId: number; + cashflowTransactionId: number; + cashflowTransaction: ICashflowTransaction; + trx: Knex.Transaction; +} +export interface ICashflowTransactionUncategorizingPayload { + tenantId: number; + uncategorizedTransaction: IUncategorizedCashflowTransaction; + trx: Knex.Transaction; +} +export interface ICashflowTransactionUncategorizedPayload { + tenantId: number; + uncategorizedTransaction: IUncategorizedCashflowTransaction; + oldUncategorizedTransaction: IUncategorizedCashflowTransaction; + trx: Knex.Transaction; +} + export enum CashflowAction { Create = 'Create', Delete = 'Delete', View = 'View', } + +export interface CategorizeTransactionAsExpenseDTO { + expenseAccountId: number; + exchangeRate: number; + referenceNo: string; + description: string; + branchId?: number; +} + +export interface IGetUncategorizedTransactionsQuery { + page?: number; + pageSize?: number; +} diff --git a/packages/server/src/interfaces/CustomerBalanceSummary.ts b/packages/server/src/interfaces/CustomerBalanceSummary.ts index cf5b5900d..c55452d98 100644 --- a/packages/server/src/interfaces/CustomerBalanceSummary.ts +++ b/packages/server/src/interfaces/CustomerBalanceSummary.ts @@ -4,6 +4,7 @@ import { IContactBalanceSummaryPercentage, IContactBalanceSummaryTotal, } from './ContactBalanceSummary'; +import { IFinancialSheetCommonMeta } from './FinancialStatements'; import { IFinancialTable } from './Table'; export interface ICustomerBalanceSummaryQuery @@ -35,9 +36,15 @@ export interface ICustomerBalanceSummaryData { total: ICustomerBalanceSummaryTotal; } +export interface ICustomerBalanceSummaryMeta extends IFinancialSheetCommonMeta { + formattedAsDate: string; + formattedDateRange: string; +} + export interface ICustomerBalanceSummaryStatement { data: ICustomerBalanceSummaryData; query: ICustomerBalanceSummaryQuery; + meta: ICustomerBalanceSummaryMeta; } export interface ICustomerBalanceSummaryService { @@ -49,4 +56,5 @@ export interface ICustomerBalanceSummaryService { export interface ICustomerBalanceSummaryTable extends IFinancialTable { query: ICustomerBalanceSummaryQuery; + meta: ICustomerBalanceSummaryMeta; } diff --git a/packages/server/src/interfaces/ExchangeRate.ts b/packages/server/src/interfaces/ExchangeRate.ts index fc3bd33e4..45080fc0f 100644 --- a/packages/server/src/interfaces/ExchangeRate.ts +++ b/packages/server/src/interfaces/ExchangeRate.ts @@ -1,36 +1,10 @@ -import { IFilterRole } from './DynamicFilter'; +export interface ExchangeRateLatestDTO { + toCurrency: string; + fromCurrency: string; +} -export interface IExchangeRate { - id: number, - currencyCode: string, - exchangeRate: number, - date: Date, - createdAt: Date, - updatedAt: Date, -}; - -export interface IExchangeRateDTO { - currencyCode: string, - exchangeRate: number, - date: Date, -}; - -export interface IExchangeRateEditDTO { - exchangeRate: number, -}; - -export interface IExchangeRateFilter { - page: number, - pageSize: number, - filterRoles?: IFilterRole[]; - columnSortBy: string; - sortOrder: string; -}; - -export interface IExchangeRatesService { - newExchangeRate(tenantId: number, exchangeRateDTO: IExchangeRateDTO): Promise; - editExchangeRate(tenantId: number, exchangeRateId: number, editExRateDTO: IExchangeRateEditDTO): Promise; - - deleteExchangeRate(tenantId: number, exchangeRateId: number): Promise; - listExchangeRates(tenantId: number, exchangeRateFilter: IExchangeRateFilter): Promise; -}; \ No newline at end of file +export interface EchangeRateLatestPOJO { + baseCurrency: string; + toCurrency: string; + exchangeRate: number; +} diff --git a/packages/server/src/interfaces/FinancialStatements.ts b/packages/server/src/interfaces/FinancialStatements.ts index fb1d77452..abf2468c6 100644 --- a/packages/server/src/interfaces/FinancialStatements.ts +++ b/packages/server/src/interfaces/FinancialStatements.ts @@ -42,4 +42,13 @@ export enum ReportsAction { export interface IFinancialSheetBranchesQuery { branchesIds?: number[]; -} \ No newline at end of file +} + +export interface IFinancialSheetCommonMeta { + organizationName: string; + baseCurrency: string; + dateFormat: string; + isCostComputeRunning: boolean; + sheetName: string; + +} diff --git a/packages/server/src/interfaces/GeneralLedgerSheet.ts b/packages/server/src/interfaces/GeneralLedgerSheet.ts index bf1662086..6de1bda3b 100644 --- a/packages/server/src/interfaces/GeneralLedgerSheet.ts +++ b/packages/server/src/interfaces/GeneralLedgerSheet.ts @@ -1,81 +1,90 @@ - +import { IFinancialSheetCommonMeta } from './FinancialStatements'; +import { IFinancialTable } from './Table'; export interface IGeneralLedgerSheetQuery { - fromDate: Date | string, - toDate: Date | string, - basis: string, + fromDate: Date | string; + toDate: Date | string; + basis: string; numberFormat: { - noCents: boolean, - divideOn1000: boolean, - }, - noneTransactions: boolean, - accountsIds: number[], + noCents: boolean; + divideOn1000: boolean; + }; + noneTransactions: boolean; + accountsIds: number[]; branchesIds?: number[]; -}; +} export interface IGeneralLedgerSheetAccountTransaction { - id: number, + id: number; - amount: number, - runningBalance: number, - credit: number, - debit: number, + amount: number; + runningBalance: number; + credit: number; + debit: number; - formattedAmount: string, - formattedCredit: string, - formattedDebit: string, - formattedRunningBalance: string, + formattedAmount: string; + formattedCredit: string; + formattedDebit: string; + formattedRunningBalance: string; - currencyCode: string, - note?: string, + currencyCode: string; + note?: string; - transactionType?: string, - transactionNumber: string, + transactionType?: string; + transactionNumber: string; - referenceId?: number, - referenceType?: string, + referenceId?: number; + referenceType?: string; - date: Date|string, -}; + date: Date | string; + dateFormatted: string; +} export interface IGeneralLedgerSheetAccountBalance { - date: Date|string, - amount: number, - formattedAmount: string, - currencyCode: string, + date: Date | string; + amount: number; + formattedAmount: string; + currencyCode: string; } export interface IGeneralLedgerSheetAccount { - id: number, - name: string, - code: string, - index: number, - parentAccountId: number, - transactions: IGeneralLedgerSheetAccountTransaction[], - openingBalance: IGeneralLedgerSheetAccountBalance, - closingBalance: IGeneralLedgerSheetAccountBalance, + id: number; + name: string; + code: string; + index: number; + parentAccountId: number; + transactions: IGeneralLedgerSheetAccountTransaction[]; + openingBalance: IGeneralLedgerSheetAccountBalance; + closingBalance: IGeneralLedgerSheetAccountBalance; } +export type IGeneralLedgerSheetData = IGeneralLedgerSheetAccount[]; + export interface IAccountTransaction { - id: number, - index: number, - draft: boolean, - note: string, - accountId: number, - transactionType: string, - referenceType: string, - referenceId: number, - contactId: number, - contactType: string, - credit: number, - debit: number, - date: string|Date, - createdAt: string|Date, - updatedAt: string|Date, + id: number; + index: number; + draft: boolean; + note: string; + accountId: number; + transactionType: string; + referenceType: string; + referenceId: number; + contactId: number; + contactType: string; + credit: number; + debit: number; + date: string | Date; + createdAt: string | Date; + updatedAt: string | Date; } -export interface IGeneralLedgerMeta { - isCostComputeRunning: boolean, - organizationName: string, - baseCurrency: string, -}; \ No newline at end of file +export interface IGeneralLedgerMeta extends IFinancialSheetCommonMeta { + formattedFromDate: string; + formattedToDate: string; + formattedDateRange: string; +} + +export interface IGeneralLedgerTableData extends IFinancialTable { + meta: IGeneralLedgerMeta; + query: IGeneralLedgerSheetQuery; +} diff --git a/packages/server/src/interfaces/IInventoryValuationSheet.ts b/packages/server/src/interfaces/IInventoryValuationSheet.ts index dedb6c483..a5bd5d051 100644 --- a/packages/server/src/interfaces/IInventoryValuationSheet.ts +++ b/packages/server/src/interfaces/IInventoryValuationSheet.ts @@ -1,4 +1,8 @@ -import { INumberFormatQuery } from './FinancialStatements'; +import { + IFinancialSheetCommonMeta, + INumberFormatQuery, +} from './FinancialStatements'; +import { IFinancialTable } from './Table'; export interface IInventoryValuationReportQuery { asDate: Date | string; @@ -12,10 +16,10 @@ export interface IInventoryValuationReportQuery { branchesIds?: number[]; } -export interface IInventoryValuationSheetMeta { - organizationName: string; - baseCurrency: string; - isCostComputeRunning: boolean; +export interface IInventoryValuationSheetMeta + extends IFinancialSheetCommonMeta { + formattedAsDate: string; + formattedDateRange: string; } export interface IInventoryValuationItem { @@ -39,9 +43,19 @@ export interface IInventoryValuationTotal { quantityFormatted: string; } -export type IInventoryValuationStatement = - | { - items: IInventoryValuationItem[]; - total: IInventoryValuationTotal; - } - | {}; +export type IInventoryValuationStatement = { + items: IInventoryValuationItem[]; + total: IInventoryValuationTotal; +}; +export type IInventoryValuationSheetData = IInventoryValuationStatement; + +export interface IInventoryValuationSheet { + data: IInventoryValuationStatement; + meta: IInventoryValuationSheetMeta; + query: IInventoryValuationReportQuery; +} + +export interface IInventoryValuationTable extends IFinancialTable { + meta: IInventoryValuationSheetMeta; + query: IInventoryValuationReportQuery; +} diff --git a/packages/server/src/interfaces/InventoryDetails.ts b/packages/server/src/interfaces/InventoryDetails.ts index 033ec269d..8da644540 100644 --- a/packages/server/src/interfaces/InventoryDetails.ts +++ b/packages/server/src/interfaces/InventoryDetails.ts @@ -1,4 +1,7 @@ -import { INumberFormatQuery } from './FinancialStatements'; +import { + IFinancialSheetCommonMeta, + INumberFormatQuery, +} from './FinancialStatements'; import { IFinancialTable } from './Table'; export interface IInventoryDetailsQuery { @@ -79,10 +82,10 @@ export type IInventoryDetailsNode = | IInventoryDetailsItemTransaction; export type IInventoryDetailsData = IInventoryDetailsItem[]; -export interface IInventoryItemDetailMeta { - isCostComputeRunning: boolean; - organizationName: string; - baseCurrency: string; +export interface IInventoryItemDetailMeta extends IFinancialSheetCommonMeta { + formattedFromDate: string; + formattedToDay: string; + formattedDateRange: string; } export interface IInvetoryItemDetailDOO { diff --git a/packages/server/src/interfaces/JournalReport.ts b/packages/server/src/interfaces/JournalReport.ts index 9786e1634..319bf526c 100644 --- a/packages/server/src/interfaces/JournalReport.ts +++ b/packages/server/src/interfaces/JournalReport.ts @@ -1,36 +1,53 @@ +import { IFinancialSheetCommonMeta } from './FinancialStatements'; import { IJournalEntry } from './Journal'; +import { IFinancialTable } from './Table'; export interface IJournalReportQuery { - fromDate: Date | string, - toDate: Date | string, + fromDate: Date | string; + toDate: Date | string; numberFormat: { - noCents: boolean, - divideOn1000: boolean, - }, - transactionType: string, - transactionId: string, + noCents: boolean; + divideOn1000: boolean; + }; + transactionType: string; + transactionId: string; - accountsIds: number | number[], - fromRange: number, - toRange: number, + accountsIds: number | number[]; + fromRange: number; + toRange: number; } export interface IJournalReportEntriesGroup { - id: string, - entries: IJournalEntry[], - currencyCode: string, - credit: number, - debit: number, - formattedCredit: string, - formattedDebit: string, + id: string; + date: Date; + dateFormatted: string; + entries: IJournalEntry[]; + currencyCode: string; + credit: number; + debit: number; + formattedCredit: string; + formattedDebit: string; } export interface IJournalReport { - entries: IJournalReportEntriesGroup[], + entries: IJournalReportEntriesGroup[]; } -export interface IJournalSheetMeta { - isCostComputeRunning: boolean, - organizationName: string, - baseCurrency: string, -} \ No newline at end of file +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/src/interfaces/Mailable.ts b/packages/server/src/interfaces/Mailable.ts index 36cc3c81f..5682f2529 100644 --- a/packages/server/src/interfaces/Mailable.ts +++ b/packages/server/src/interfaces/Mailable.ts @@ -1,9 +1,17 @@ +export type IMailAttachment = MailAttachmentPath | MailAttachmentContent; + +export interface MailAttachmentPath { + filename: string; + path: string; + cid: string; +} +export interface MailAttachmentContent { + filename: string; + content: Buffer; +} export interface IMailable { - constructor( - view: string, - data?: { [key: string]: string | number }, - ); + constructor(view: string, data?: { [key: string]: string | number }); send(): Promise; build(): void; setData(data: { [key: string]: string | number }): IMailable; @@ -13,4 +21,27 @@ export interface IMailable { setView(view: string): IMailable; render(data?: { [key: string]: string | number }): string; getViewContent(): string; -} \ No newline at end of file +} + +export interface AddressItem { + label: string; + mail: string; + primary?: boolean; +} + +export interface CommonMailOptions { + toAddresses: AddressItem[]; + fromAddresses: AddressItem[]; + from: string; + to: string | string[]; + subject: string; + body: string; + data?: Record; +} + +export interface CommonMailOptionsDTO { + to?: string | string[]; + from?: string; + subject?: string; + body?: string; +} diff --git a/packages/server/src/interfaces/PaymentReceive.ts b/packages/server/src/interfaces/PaymentReceive.ts index 6f8d8552a..329d0d944 100644 --- a/packages/server/src/interfaces/PaymentReceive.ts +++ b/packages/server/src/interfaces/PaymentReceive.ts @@ -1,5 +1,9 @@ import { Knex } from 'knex'; -import { ISystemUser } from '@/interfaces'; +import { + CommonMailOptions, + CommonMailOptionsDTO, + ISystemUser, +} from '@/interfaces'; import { ILedgerEntry } from './Ledger'; import { ISaleInvoice } from './SaleInvoice'; @@ -19,7 +23,7 @@ export interface IPaymentReceive { createdAt: Date; updatedAt: Date; localAmount?: number; - branchId?: number + branchId?: number; } export interface IPaymentReceiveCreateDTO { customerId: number; @@ -165,3 +169,13 @@ export type IPaymentReceiveGLCommonEntry = Pick< | 'createdAt' | 'branchId' >; + +export interface PaymentReceiveMailOpts extends CommonMailOptions {} + +export interface PaymentReceiveMailOptsDTO extends CommonMailOptionsDTO {} + +export interface PaymentReceiveMailPresendEvent { + tenantId: number; + paymentReceiveId: number; + messageOptions: PaymentReceiveMailOptsDTO; +} diff --git a/packages/server/src/interfaces/Plaid.ts b/packages/server/src/interfaces/Plaid.ts new file mode 100644 index 000000000..a8ad469df --- /dev/null +++ b/packages/server/src/interfaces/Plaid.ts @@ -0,0 +1,56 @@ +export interface IPlaidItemCreatedEventPayload { + tenantId: number; + plaidAccessToken: string; + plaidItemId: string; + plaidInstitutionId: string; +} + +export interface PlaidItemDTO { + publicToken: string; + institutionId: string; +} + +export interface PlaidAccount { + account_id: string; + balances: { + available: number; + current: number; + iso_currency_code: string; + limit: null; + unofficial_currency_code: null; + }; + mask: string; + name: string; + official_name: string; + persistent_account_id: string; + subtype: string; + type: string; +} + +export interface PlaidTransaction { + date: string; + account_id: string; + amount: number; + authorized_date: string; + name: string; + category: string[]; + check_number: number | null; + iso_currency_code: string; + transaction_id: string; + transaction_type: string; + payment_meta: { reference_number: string | null; payee: string | null }; +} + +export interface PlaidFetchedTransactionsUpdates { + added: any[]; + modified: any[]; + removed: any[]; + accessToken: string; + cursor: string; +} + +export interface SyncAccountsTransactionsTask { + tenantId: number; + plaidAccountId: number; + plaidTransactions: PlaidTransaction[]; +} diff --git a/packages/server/src/interfaces/ProfitLossSheet.ts b/packages/server/src/interfaces/ProfitLossSheet.ts index 944a0a950..64b787667 100644 --- a/packages/server/src/interfaces/ProfitLossSheet.ts +++ b/packages/server/src/interfaces/ProfitLossSheet.ts @@ -1,5 +1,6 @@ import { IFinancialSheetBranchesQuery, + IFinancialSheetCommonMeta, INumberFormatQuery, } from './FinancialStatements'; import { IFinancialTable } from './Table'; @@ -134,10 +135,10 @@ export type IProfitLossSheetNode = | IProfitLossSheetEquationNode | IProfitLossSheetAccountNode; -export interface IProfitLossSheetMeta { - isCostComputeRunning: boolean; - organizationName: string; - baseCurrency: string; +export interface IProfitLossSheetMeta extends IFinancialSheetCommonMeta{ + formattedDateRange: string; + formattedFromDate: string; + formattedToDate: string; } // ------------------------------------------------ diff --git a/packages/server/src/interfaces/PurchasesByItemsSheet.ts b/packages/server/src/interfaces/PurchasesByItemsSheet.ts new file mode 100644 index 000000000..fcd1ee8d9 --- /dev/null +++ b/packages/server/src/interfaces/PurchasesByItemsSheet.ts @@ -0,0 +1,58 @@ +import { + IFinancialSheetCommonMeta, + INumberFormatQuery, +} from './FinancialStatements'; +import { IFinancialTable } from './Table'; + +export interface IPurchasesByItemsReportQuery { + fromDate: Date | string; + toDate: Date | string; + itemsIds: number[]; + numberFormat: INumberFormatQuery; + noneTransactions: boolean; + onlyActive: boolean; +} + +export interface IPurchasesByItemsSheetMeta extends IFinancialSheetCommonMeta { + formattedFromDate: string; + formattedToDate: string; + formattedDateRange: string; +} + +export interface IPurchasesByItemsItem { + id: number; + name: string; + code: string; + quantitySold: number; + soldCost: number; + averageSellPrice: number; + + quantitySoldFormatted: string; + soldCostFormatted: string; + averageSellPriceFormatted: string; + currencyCode: string; +} + +export interface IPurchasesByItemsTotal { + quantitySold: number; + soldCost: number; + quantitySoldFormatted: string; + soldCostFormatted: string; + currencyCode: string; +} + +export type IPurchasesByItemsSheetData = { + items: IPurchasesByItemsItem[]; + total: IPurchasesByItemsTotal; +}; + +export interface IPurchasesByItemsSheet { + data: IPurchasesByItemsSheetData; + query: IPurchasesByItemsReportQuery; + meta: IPurchasesByItemsSheetMeta; +} + +export interface IPurchasesByItemsTable extends IFinancialTable { + query: IPurchasesByItemsReportQuery; + meta: IPurchasesByItemsSheetMeta; +} diff --git a/packages/server/src/interfaces/SaleEstimate.ts b/packages/server/src/interfaces/SaleEstimate.ts index f2a820e98..9ac17295c 100644 --- a/packages/server/src/interfaces/SaleEstimate.ts +++ b/packages/server/src/interfaces/SaleEstimate.ts @@ -1,6 +1,7 @@ import { Knex } from 'knex'; import { IItemEntry, IItemEntryDTO } from './ItemEntry'; import { IDynamicListFilterDTO } from '@/interfaces/DynamicFilter'; +import { CommonMailOptions, CommonMailOptionsDTO } from './Mailable'; export interface ISaleEstimate { id?: number; @@ -124,3 +125,17 @@ export interface ISaleEstimateApprovedEvent { saleEstimate: ISaleEstimate; trx: Knex.Transaction; } + +export interface SaleEstimateMailOptions extends CommonMailOptions { + attachEstimate?: boolean; +} + +export interface SaleEstimateMailOptionsDTO extends CommonMailOptionsDTO { + attachEstimate?: boolean; +} + +export interface ISaleEstimateMailPresendEvent { + tenantId: number; + saleEstimateId: number; + messageOptions: SaleEstimateMailOptionsDTO; +} diff --git a/packages/server/src/interfaces/SaleInvoice.ts b/packages/server/src/interfaces/SaleInvoice.ts index 7ef8fdea2..61594306a 100644 --- a/packages/server/src/interfaces/SaleInvoice.ts +++ b/packages/server/src/interfaces/SaleInvoice.ts @@ -1,5 +1,6 @@ import { Knex } from 'knex'; import { ISystemUser, IAccount, ITaxTransaction } from '@/interfaces'; +import { CommonMailOptions, CommonMailOptionsDTO } from './Mailable'; import { IDynamicListFilter } from '@/interfaces/DynamicFilter'; import { IItemEntry, IItemEntryDTO } from './ItemEntry'; @@ -186,3 +187,29 @@ export enum SaleInvoiceAction { Writeoff = 'Writeoff', NotifyBySms = 'NotifyBySms', } + +export interface SaleInvoiceMailOptions extends CommonMailOptions { + attachInvoice: boolean; +} + +export interface SendInvoiceMailDTO extends CommonMailOptionsDTO { + attachInvoice?: boolean; +} + +export interface ISaleInvoiceNotifyPayload { + tenantId: number; + saleInvoiceId: number; + messageDTO: SendInvoiceMailDTO; +} + +export interface ISaleInvoiceMailSend { + tenantId: number; + saleInvoiceId: number; + messageOptions: SendInvoiceMailDTO; +} + +export interface ISaleInvoiceMailSent { + tenantId: number; + saleInvoiceId: number; + messageOptions: SendInvoiceMailDTO; +} diff --git a/packages/server/src/interfaces/SaleReceipt.ts b/packages/server/src/interfaces/SaleReceipt.ts index 4d319ec4f..8904767c6 100644 --- a/packages/server/src/interfaces/SaleReceipt.ts +++ b/packages/server/src/interfaces/SaleReceipt.ts @@ -1,5 +1,6 @@ import { Knex } from 'knex'; import { IItemEntry } from './ItemEntry'; +import { CommonMailOptions, CommonMailOptionsDTO } from './Mailable'; export interface ISaleReceipt { id?: number; @@ -134,3 +135,17 @@ export interface ISaleReceiptDeletingPayload { oldSaleReceipt: ISaleReceipt; trx: Knex.Transaction; } + +export interface SaleReceiptMailOpts extends CommonMailOptions { + attachReceipt: boolean; +} + +export interface SaleReceiptMailOptsDTO extends CommonMailOptionsDTO { + attachReceipt?: boolean; +} + +export interface ISaleReceiptMailPresend { + tenantId: number; + saleReceiptId: number; + messageOptions: SaleReceiptMailOptsDTO; +} diff --git a/packages/server/src/interfaces/SalesByItemsSheet.ts b/packages/server/src/interfaces/SalesByItemsSheet.ts index 0e0a41f9e..789ebae77 100644 --- a/packages/server/src/interfaces/SalesByItemsSheet.ts +++ b/packages/server/src/interfaces/SalesByItemsSheet.ts @@ -1,45 +1,58 @@ import { + IFinancialSheetCommonMeta, INumberFormatQuery, } from './FinancialStatements'; +import { IFinancialTable } from './Table'; export interface ISalesByItemsReportQuery { fromDate: Date | string; toDate: Date | string; - itemsIds: number[], + itemsIds: number[]; numberFormat: INumberFormatQuery; noneTransactions: boolean; - onlyActive: boolean; -}; + onlyActive: boolean; +} -export interface ISalesByItemsSheetMeta { - organizationName: string, - baseCurrency: string, -}; +export interface ISalesByItemsSheetMeta extends IFinancialSheetCommonMeta { + formattedFromDate: string; + formattedToDate: string; + formattedDateRange: string; +} export interface ISalesByItemsItem { - id: number, - name: string, - code: string, - quantitySold: number, - soldCost: number, - averageSellPrice: number, + id: number; + name: string; + code: string; + quantitySold: number; + soldCost: number; + averageSellPrice: number; - quantitySoldFormatted: string, - soldCostFormatted: string, - averageSellPriceFormatted: string, - currencyCode: string, -}; + quantitySoldFormatted: string; + soldCostFormatted: string; + averageSellPriceFormatted: string; + currencyCode: string; +} export interface ISalesByItemsTotal { - quantitySold: number, - soldCost: number, - quantitySoldFormatted: string, - soldCostFormatted: string, - currencyCode: string, + quantitySold: number; + soldCost: number; + quantitySoldFormatted: string; + soldCostFormatted: string; + currencyCode: string; +} + +export type ISalesByItemsSheetData = { + items: ISalesByItemsItem[]; + total: ISalesByItemsTotal; }; -export type ISalesByItemsSheetStatement = { - items: ISalesByItemsItem[], - total: ISalesByItemsTotal -} | {}; +export interface ISalesByItemsSheet { + data: ISalesByItemsSheetData; + query: ISalesByItemsReportQuery; + meta: ISalesByItemsSheetMeta; +} +export interface ISalesByItemsTable extends IFinancialTable { + query: ISalesByItemsReportQuery; + meta: ISalesByItemsSheetMeta; +} diff --git a/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts b/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts index 2fbc9f13d..b37d8a384 100644 --- a/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts +++ b/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts @@ -1,3 +1,4 @@ +import { IFinancialSheetCommonMeta } from "./FinancialStatements"; import { IFinancialTable } from "./Table"; export interface SalesTaxLiabilitySummaryQuery { @@ -47,9 +48,10 @@ export type SalesTaxLiabilitySummarySalesById = Record< { taxRateId: number; credit: number; debit: number } >; -export interface SalesTaxLiabilitySummaryMeta { - organizationName: string; - baseCurrency: string; +export interface SalesTaxLiabilitySummaryMeta extends IFinancialSheetCommonMeta { + formattedFromDate: string; + formattedToDate: string; + formattedDateRange: string; } export interface ISalesTaxLiabilitySummaryTable extends IFinancialTable { diff --git a/packages/server/src/interfaces/TransactionsByCustomers.ts b/packages/server/src/interfaces/TransactionsByCustomers.ts index fcfb01ea6..e47098b36 100644 --- a/packages/server/src/interfaces/TransactionsByCustomers.ts +++ b/packages/server/src/interfaces/TransactionsByCustomers.ts @@ -1,3 +1,4 @@ +import { IFinancialSheetCommonMeta } from './FinancialStatements'; import { IFinancialTable, ITableData } from './Table'; import { ITransactionsByContactsAmount, @@ -28,10 +29,12 @@ export type ITransactionsByCustomersData = ITransactionsByCustomersCustomer[]; export interface ITransactionsByCustomersStatement { data: ITransactionsByCustomersData; query: ITransactionsByCustomersFilter; + meta: ITransactionsByCustomersMeta; } export interface ITransactionsByCustomersTable extends IFinancialTable { query: ITransactionsByCustomersFilter; + meta: ITransactionsByCustomersMeta; } export interface ITransactionsByCustomersService { @@ -40,3 +43,9 @@ export interface ITransactionsByCustomersService { filter: ITransactionsByCustomersFilter ): Promise; } +export interface ITransactionsByCustomersMeta + extends IFinancialSheetCommonMeta { + formattedFromDate: string; + formattedToDate: string; + formattedDateRange: string; +} diff --git a/packages/server/src/interfaces/TransactionsByVendors.ts b/packages/server/src/interfaces/TransactionsByVendors.ts index ae129bc79..7a5b16013 100644 --- a/packages/server/src/interfaces/TransactionsByVendors.ts +++ b/packages/server/src/interfaces/TransactionsByVendors.ts @@ -1,3 +1,4 @@ +import { IFinancialSheetCommonMeta } from './FinancialStatements'; import { IFinancialTable } from './Table'; import { ITransactionsByContactsAmount, @@ -27,6 +28,8 @@ export type ITransactionsByVendorsData = ITransactionsByVendorsVendor[]; export interface ITransactionsByVendorsStatement { data: ITransactionsByVendorsData; + query: ITransactionsByVendorsFilter; + meta: ITransactionsByVendorMeta; } export interface ITransactionsByVendorsService { @@ -38,4 +41,10 @@ export interface ITransactionsByVendorsService { export interface ITransactionsByVendorTable extends IFinancialTable { query: ITransactionsByVendorsFilter; -} \ No newline at end of file + meta: ITransactionsByVendorMeta; +} +export interface ITransactionsByVendorMeta extends IFinancialSheetCommonMeta { + formattedFromDate: string; + formattedToDate: string; + formattedDateRange: string; +} diff --git a/packages/server/src/interfaces/TrialBalanceSheet.ts b/packages/server/src/interfaces/TrialBalanceSheet.ts index f423503ae..b6cd79736 100644 --- a/packages/server/src/interfaces/TrialBalanceSheet.ts +++ b/packages/server/src/interfaces/TrialBalanceSheet.ts @@ -1,4 +1,7 @@ -import { INumberFormatQuery } from './FinancialStatements'; +import { + IFinancialSheetCommonMeta, + INumberFormatQuery, +} from './FinancialStatements'; import { IFinancialTable } from './Table'; export interface ITrialBalanceSheetQuery { @@ -24,10 +27,10 @@ export interface ITrialBalanceTotal { formattedBalance: string; } -export interface ITrialBalanceSheetMeta { - isCostComputeRunning: boolean; - organizationName: string; - baseCurrency: string; +export interface ITrialBalanceSheetMeta extends IFinancialSheetCommonMeta { + formattedFromDate: string; + formattedToDate: string; + formattedDateRange: string; } export interface ITrialBalanceAccount extends ITrialBalanceTotal { diff --git a/packages/server/src/interfaces/VendorBalanceSummary.ts b/packages/server/src/interfaces/VendorBalanceSummary.ts index d214202df..5c603c51d 100644 --- a/packages/server/src/interfaces/VendorBalanceSummary.ts +++ b/packages/server/src/interfaces/VendorBalanceSummary.ts @@ -1,4 +1,7 @@ -import { INumberFormatQuery } from './FinancialStatements'; +import { + IFinancialSheetCommonMeta, + INumberFormatQuery, +} from './FinancialStatements'; import { IFinancialTable } from './Table'; export interface IVendorBalanceSummaryQuery { @@ -39,8 +42,9 @@ export interface IVendorBalanceSummaryData { export interface IVendorBalanceSummaryStatement { data: IVendorBalanceSummaryData; - columns: {}; query: IVendorBalanceSummaryQuery; + meta: IVendorBalanceSummaryMeta; + } export interface IVendorBalanceSummaryService { @@ -52,4 +56,9 @@ export interface IVendorBalanceSummaryService { export interface IVendorBalanceSummaryTable extends IFinancialTable { query: IVendorBalanceSummaryQuery; + meta: IVendorBalanceSummaryMeta; +} + +export interface IVendorBalanceSummaryMeta extends IFinancialSheetCommonMeta { + formattedAsDate: string; } diff --git a/packages/server/src/interfaces/index.ts b/packages/server/src/interfaces/index.ts index 1b23eedd3..858acba51 100644 --- a/packages/server/src/interfaces/index.ts +++ b/packages/server/src/interfaces/index.ts @@ -74,6 +74,7 @@ export * from './Tasks'; export * from './Times'; export * from './ProjectProfitabilitySummary'; export * from './TaxRate'; +export * from './Plaid'; export interface I18nService { __: (input: string) => string; diff --git a/packages/server/src/lib/ExchangeRate/ExchangeRate.ts b/packages/server/src/lib/ExchangeRate/ExchangeRate.ts new file mode 100644 index 000000000..5bb84cff3 --- /dev/null +++ b/packages/server/src/lib/ExchangeRate/ExchangeRate.ts @@ -0,0 +1,45 @@ +import { OpenExchangeRate } from './OpenExchangeRate'; +import { ExchangeRateServiceType, IExchangeRateService } from './types'; + +export class ExchangeRate { + private exchangeRateService: IExchangeRateService; + private exchangeRateServiceType: ExchangeRateServiceType; + + /** + * Constructor method. + * @param {ExchangeRateServiceType} service + */ + constructor(service: ExchangeRateServiceType) { + this.exchangeRateServiceType = service; + this.initService(); + } + + /** + * Initialize the exchange rate service based on the service type. + */ + private initService() { + if ( + this.exchangeRateServiceType === ExchangeRateServiceType.OpenExchangeRate + ) { + this.setExchangeRateService(new OpenExchangeRate()); + } + } + + /** + * Sets the exchange rate service. + * @param {IExchangeRateService} service + */ + private setExchangeRateService(service: IExchangeRateService) { + this.exchangeRateService = service; + } + + /** + * Gets the latest exchange rate. + * @param {string} baseCurrency + * @param {string} toCurrency + * @returns {number} + */ + public latest(baseCurrency: string, toCurrency: string): Promise { + return this.exchangeRateService.latest(baseCurrency, toCurrency); + } +} diff --git a/packages/server/src/lib/ExchangeRate/OpenExchangeRate.ts b/packages/server/src/lib/ExchangeRate/OpenExchangeRate.ts new file mode 100644 index 000000000..221c5c5c5 --- /dev/null +++ b/packages/server/src/lib/ExchangeRate/OpenExchangeRate.ts @@ -0,0 +1,81 @@ +import Axios, { AxiosError } from 'axios'; +import { + EchangeRateErrors, + IExchangeRateService, + OPEN_EXCHANGE_RATE_LATEST_URL, +} from './types'; +import config from '@/config'; +import { ServiceError } from '@/exceptions'; + +export class OpenExchangeRate implements IExchangeRateService { + /** + * Gets the latest exchange rate. + * @param {string} baseCurrency + * @param {string} toCurrency + * @returns {Promise { + // Vaclidates the Open Exchange Rate api id early. + this.validateApiIdExistance(); + + try { + const result = await Axios.get(OPEN_EXCHANGE_RATE_LATEST_URL, { + params: { + app_id: config.exchangeRate.openExchangeRate.appId, + base: baseCurrency, + symbols: toCurrency, + }, + }); + return result.data.rates[toCurrency] || (1 as number); + } catch (error) { + this.handleLatestErrors(error); + } + } + + /** + * Validates the Open Exchange Rate api id. + * @throws {ServiceError} + */ + private validateApiIdExistance() { + const apiId = config.exchangeRate.openExchangeRate.appId; + + if (!apiId) { + throw new ServiceError( + EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED, + 'Invalid App ID provided. Please sign up at https://openexchangerates.org/signup, or contact support@openexchangerates.org.' + ); + } + } + + /** + * Handles the latest errors. + * @param {any} error + * @throws {ServiceError} + */ + private handleLatestErrors(error: any) { + if (error.response.data?.message === 'missing_app_id') { + throw new ServiceError( + EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED, + 'Invalid App ID provided. Please sign up at https://openexchangerates.org/signup, or contact support@openexchangerates.org.' + ); + } else if (error.response.data?.message === 'invalid_app_id') { + throw new ServiceError( + EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED, + 'Invalid App ID provided. Please sign up at https://openexchangerates.org/signup, or contact support@openexchangerates.org.' + ); + } else if (error.response.data?.message === 'not_allowed') { + throw new ServiceError( + EchangeRateErrors.EX_RATE_SERVICE_NOT_ALLOWED, + 'Getting the exchange rate from the given base currency to the given currency is not allowed.' + ); + } else if (error.response.data?.message === 'invalid_base') { + throw new ServiceError( + EchangeRateErrors.EX_RATE_INVALID_BASE_CURRENCY, + 'The given base currency is invalid.' + ); + } + } +} diff --git a/packages/server/src/lib/ExchangeRate/types.ts b/packages/server/src/lib/ExchangeRate/types.ts new file mode 100644 index 000000000..8b40125cd --- /dev/null +++ b/packages/server/src/lib/ExchangeRate/types.ts @@ -0,0 +1,17 @@ +export interface IExchangeRateService { + latest(baseCurrency: string, toCurrency: string): Promise; +} + +export enum ExchangeRateServiceType { + OpenExchangeRate = 'OpenExchangeRate', +} + +export enum EchangeRateErrors { + EX_RATE_SERVICE_NOT_ALLOWED = 'EX_RATE_SERVICE_NOT_ALLOWED', + EX_RATE_LIMIT_EXCEEDED = 'EX_RATE_LIMIT_EXCEEDED', + EX_RATE_SERVICE_API_KEY_REQUIRED = 'EX_RATE_SERVICE_API_KEY_REQUIRED', + EX_RATE_INVALID_BASE_CURRENCY = 'EX_RATE_INVALID_BASE_CURRENCY', +} + +export const OPEN_EXCHANGE_RATE_LATEST_URL = + 'https://openexchangerates.org/api/latest.json'; diff --git a/packages/server/src/lib/Mail/index.ts b/packages/server/src/lib/Mail/index.ts index dd79c934b..015ca02a8 100644 --- a/packages/server/src/lib/Mail/index.ts +++ b/packages/server/src/lib/Mail/index.ts @@ -2,18 +2,13 @@ import fs from 'fs'; import Mustache from 'mustache'; import { Container } from 'typedi'; import path from 'path'; -import { IMailable } from '@/interfaces'; - -interface IMailAttachment { - filename: string; - path: string; - cid: string; -} +import { IMailAttachment } from '@/interfaces'; export default class Mail { view: string; - subject: string; - to: string; + subject: string = ''; + content: string = ''; + to: string | string[]; from: string = `${process.env.MAIL_FROM_NAME} ${process.env.MAIL_FROM_ADDRESS}`; data: { [key: string]: string | number }; attachments: IMailAttachment[]; @@ -21,16 +16,24 @@ export default class Mail { /** * Mail options. */ - private get mailOptions() { + public get mailOptions() { return { to: this.to, from: this.from, subject: this.subject, - html: this.render(this.data), + html: this.html, attachments: this.attachments, }; } + /** + * Retrieves the html content of the mail. + * @returns {string} + */ + public get html() { + return this.view ? Mail.render(this.view, this.data) : this.content; + } + /** * Sends the given mail to the target address. */ @@ -52,7 +55,7 @@ export default class Mail { * Set send mail to address. * @param {string} to - */ - setTo(to: string) { + setTo(to: string | string[]) { this.to = to; return this; } @@ -62,11 +65,16 @@ export default class Mail { * @param {string} from * @return {} */ - private setFrom(from: string) { + setFrom(from: string) { this.from = from; return this; } + /** + * Set attachments to the mail. + * @param {IMailAttachment[]} attachments + * @returns {Mail} + */ setAttachments(attachments: IMailAttachment[]) { this.attachments = attachments; return this; @@ -95,21 +103,26 @@ export default class Mail { return this; } + setContent(content: string) { + this.content = content; + return this; + } + /** * Renders the view template with the given data. * @param {object} data * @return {string} */ - render(data): string { - const viewContent = this.getViewContent(); + static render(view: string, data: Record): string { + const viewContent = Mail.getViewContent(view); return Mustache.render(viewContent, data); } /** * Retrieve view content from the view directory. */ - private getViewContent(): string { - const filePath = path.join(global.__views_dir, `/${this.view}`); + static getViewContent(view: string): string { + const filePath = path.join(global.__views_dir, `/${view}`); return fs.readFileSync(filePath, 'utf8'); } } diff --git a/packages/server/src/lib/Plaid/Plaid.ts b/packages/server/src/lib/Plaid/Plaid.ts new file mode 100644 index 000000000..d067757fb --- /dev/null +++ b/packages/server/src/lib/Plaid/Plaid.ts @@ -0,0 +1,103 @@ +import { forEach } from 'lodash'; +import { Configuration, PlaidApi, PlaidEnvironments } from 'plaid'; +import { createPlaidApiEvent } from './PlaidApiEventsDBSync'; +import config from '@/config'; + +const OPTIONS = { clientApp: 'Plaid-Pattern' }; + +// We want to log requests to / responses from the Plaid API (via the Plaid client), as this data +// can be useful for troubleshooting. + +/** + * Logging function for Plaid client methods that use an access_token as an argument. Associates + * the Plaid API event log entry with the item and user the request is for. + * + * @param {string} clientMethod the name of the Plaid client method called. + * @param {Array} clientMethodArgs the arguments passed to the Plaid client method. + * @param {Object} response the response from the Plaid client. + */ +const defaultLogger = async (clientMethod, clientMethodArgs, response) => { + const accessToken = clientMethodArgs[0].access_token; + // const { id: itemId, user_id: userId } = await retrieveItemByPlaidAccessToken( + // accessToken + // ); + // await createPlaidApiEvent(1, 1, clientMethod, clientMethodArgs, response); + + // console.log(response); +}; + +/** + * Logging function for Plaid client methods that do not use access_token as an argument. These + * Plaid API event log entries will not be associated with an item or user. + * + * @param {string} clientMethod the name of the Plaid client method called. + * @param {Array} clientMethodArgs the arguments passed to the Plaid client method. + * @param {Object} response the response from the Plaid client. + */ +const noAccessTokenLogger = async ( + clientMethod, + clientMethodArgs, + response +) => { + // console.log(response); + + // await createPlaidApiEvent( + // undefined, + // undefined, + // clientMethod, + // clientMethodArgs, + // response + // ); +}; + +// Plaid client methods used in this app, mapped to their appropriate logging functions. +const clientMethodLoggingFns = { + accountsGet: defaultLogger, + institutionsGet: noAccessTokenLogger, + institutionsGetById: noAccessTokenLogger, + itemPublicTokenExchange: noAccessTokenLogger, + itemRemove: defaultLogger, + linkTokenCreate: noAccessTokenLogger, + transactionsSync: defaultLogger, + sandboxItemResetLogin: defaultLogger, +}; +// Wrapper for the Plaid client. This allows us to easily log data for all Plaid client requests. +export class PlaidClientWrapper { + constructor() { + // Initialize the Plaid client. + const configuration = new Configuration({ + basePath: PlaidEnvironments[config.plaid.env], + baseOptions: { + headers: { + 'PLAID-CLIENT-ID': config.plaid.clientId, + 'PLAID-SECRET': + config.plaid.env === 'development' + ? config.plaid.secretDevelopment + : config.plaid.secretSandbox, + 'Plaid-Version': '2020-09-14', + }, + }, + }); + + this.client = new PlaidApi(configuration); + + // Wrap the Plaid client methods to add a logging function. + forEach(clientMethodLoggingFns, (logFn, method) => { + this[method] = this.createWrappedClientMethod(method, logFn); + }); + } + + // Allows us to log API request data for troubleshooting purposes. + createWrappedClientMethod(clientMethod, log) { + return async (...args) => { + try { + const res = await this.client[clientMethod](...args); + await log(clientMethod, args, res); + return res; + } catch (err) { + await log(clientMethod, args, err?.response?.data); + throw err; + } + }; + } +} diff --git a/packages/server/src/lib/Plaid/PlaidApiEventsDBSync.ts b/packages/server/src/lib/Plaid/PlaidApiEventsDBSync.ts new file mode 100644 index 000000000..9d257b727 --- /dev/null +++ b/packages/server/src/lib/Plaid/PlaidApiEventsDBSync.ts @@ -0,0 +1,48 @@ +/** + * Creates a single Plaid api event log entry. + * + * @param {string} itemId the item id in the request. + * @param {string} userId the user id in the request. + * @param {string} plaidMethod the Plaid client method called. + * @param {Array} clientMethodArgs the arguments passed to the Plaid client method. + * @param {Object} response the Plaid api response object. + */ +export const createPlaidApiEvent = async ( + itemId, + userId, + plaidMethod, + clientMethodArgs, + response +) => { + const { + error_code: errorCode, + error_type: errorType, + request_id: requestId, + } = response; + const query = { + text: ` + INSERT INTO plaid_api_events_table + ( + item_id, + user_id, + plaid_method, + arguments, + request_id, + error_type, + error_code + ) + VALUES + ($1, $2, $3, $4, $5, $6, $7); + `, + values: [ + itemId, + userId, + plaidMethod, + JSON.stringify(clientMethodArgs), + requestId, + errorType, + errorCode, + ], + }; + // await db.query(query); +}; diff --git a/packages/server/src/lib/Plaid/index.ts b/packages/server/src/lib/Plaid/index.ts new file mode 100644 index 000000000..9f3f903ca --- /dev/null +++ b/packages/server/src/lib/Plaid/index.ts @@ -0,0 +1 @@ +export * from './Plaid'; diff --git a/packages/server/src/lib/Xlsx/TableSheet.tsx b/packages/server/src/lib/Xlsx/TableSheet.ts similarity index 100% rename from packages/server/src/lib/Xlsx/TableSheet.tsx rename to packages/server/src/lib/Xlsx/TableSheet.ts diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index fa1942b72..584fb22d9 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -84,6 +84,12 @@ import { WriteInvoiceTaxTransactionsSubscriber } from '@/services/TaxRates/subsc import { BillTaxRateValidateSubscriber } from '@/services/TaxRates/subscribers/BillTaxRateValidateSubscriber'; import { WriteBillTaxTransactionsSubscriber } from '@/services/TaxRates/subscribers/WriteBillTaxTransactionsSubscriber'; import { SyncItemTaxRateOnEditTaxSubscriber } from '@/services/TaxRates/SyncItemTaxRateOnEditTaxSubscriber'; +import { PlaidUpdateTransactionsOnItemCreatedSubscriber } from '@/services/Banking/Plaid/subscribers/PlaidUpdateTransactionsOnItemCreatedSubscriber'; +import { InvoiceChangeStatusOnMailSentSubscriber } from '@/services/Sales/Invoices/subscribers/InvoiceChangeStatusOnMailSentSubscriber'; +import { SaleReceiptMarkClosedOnMailSentSubcriber } from '@/services/Sales/Receipts/subscribers/SaleReceiptMarkClosedOnMailSentSubcriber'; +import { SaleEstimateMarkApprovedOnMailSent } from '@/services/Sales/Estimates/subscribers/SaleEstimateMarkApprovedOnMailSent'; +import { DeleteCashflowTransactionOnUncategorize } from '@/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize'; +import { PreventDeleteTransactionOnDelete } from '@/services/Cashflow/subscribers/PreventDeleteTransactionsOnDelete'; } export default () => { return new EventPublisher(); @@ -104,8 +110,12 @@ export const susbcribers = () => { InventorySubscriber, CustomerWriteGLOpeningBalanceSubscriber, VendorsWriteGLOpeningSubscriber, + + // # Estimate SaleEstimateAutoSerialSubscriber, SaleEstimateSmsNotificationSubscriber, + SaleEstimateMarkApprovedOnMailSent, + ExpensesWriteGLSubscriber, SaleReceiptAutoSerialSubscriber, SaleInvoiceAutoIncrementSubscriber, @@ -152,11 +162,13 @@ export const susbcribers = () => { // #Invoices InvoicePaymentGLRewriteSubscriber, InvoiceCostGLEntriesSubscriber, + InvoiceChangeStatusOnMailSentSubscriber, BillPaymentsGLEntriesRewriteSubscriber, // # Receipts SaleReceiptCostGLEntriesSubscriber, + SaleReceiptMarkClosedOnMailSentSubcriber, // Transaction locking. SalesTransactionLockingGuardSubscriber, @@ -199,6 +211,13 @@ export const susbcribers = () => { BillTaxRateValidateSubscriber, WriteBillTaxTransactionsSubscriber, - SyncItemTaxRateOnEditTaxSubscriber + SyncItemTaxRateOnEditTaxSubscriber, + + // Plaid + PlaidUpdateTransactionsOnItemCreatedSubscriber, + + // Cashflow + DeleteCashflowTransactionOnUncategorize, + PreventDeleteTransactionOnDelete ]; }; diff --git a/packages/server/src/loaders/express.ts b/packages/server/src/loaders/express.ts index cf9787a95..694de8574 100644 --- a/packages/server/src/loaders/express.ts +++ b/packages/server/src/loaders/express.ts @@ -5,6 +5,8 @@ import boom from 'express-boom'; import errorHandler from 'errorhandler'; import bodyParser from 'body-parser'; import fileUpload from 'express-fileupload'; +import { Server } from 'socket.io'; +import Container from 'typedi'; import routes from 'api'; import LoggerMiddleware from '@/api/middleware/LoggerMiddleware'; import AgendashController from '@/api/controllers/Agendash'; @@ -72,4 +74,32 @@ export default ({ app }) => { app.use((req: Request, res: Response, next: NextFunction) => { return res.boom.notFound(); }); + const server = app.listen(app.get('port'), (err) => { + if (err) { + console.log(err); + process.exit(1); + return; + } + console.log(` + ################################################ + Server listening on port: ${app.get('port')} + ################################################ + `); + }); + const io = new Server(server, { path: '/socket' }); + + // Set socket.io listeners. + io.on('connection', (socket) => { + console.log('SOCKET CONNECTED'); + + socket.on('disconnect', () => { + console.log('SOCKET DISCONNECTED'); + }); + }); + // Middleware to pass socket to each request object. + app.use((req: Request, res: Response, next: NextFunction) => { + req.io = io; + next(); + }); + Container.set('socket', io); }; diff --git a/packages/server/src/loaders/jobs.ts b/packages/server/src/loaders/jobs.ts index 4fa3aadb1..3215c9558 100644 --- a/packages/server/src/loaders/jobs.ts +++ b/packages/server/src/loaders/jobs.ts @@ -5,6 +5,12 @@ import RewriteInvoicesJournalEntries from 'jobs/WriteInvoicesJEntries'; import UserInviteMailJob from 'jobs/UserInviteMail'; import OrganizationSetupJob from 'jobs/OrganizationSetup'; import OrganizationUpgrade from 'jobs/OrganizationUpgrade'; +import { SendSaleInvoiceMailJob } from '@/services/Sales/Invoices/SendSaleInvoiceMailJob'; +import { SendSaleInvoiceReminderMailJob } from '@/services/Sales/Invoices/SendSaleInvoiceMailReminderJob'; +import { SendSaleEstimateMailJob } from '@/services/Sales/Estimates/SendSaleEstimateMailJob'; +import { SaleReceiptMailNotificationJob } from '@/services/Sales/Receipts/SaleReceiptMailNotificationJob'; +import { PaymentReceiveMailNotificationJob } from '@/services/Sales/PaymentReceives/PaymentReceiveMailNotificationJob'; +import { PlaidFetchTransactionsJob } from '@/services/Banking/Plaid/PlaidFetchTransactionsJob'; export default ({ agenda }: { agenda: Agenda }) => { new ResetPasswordMailJob(agenda); @@ -13,6 +19,12 @@ export default ({ agenda }: { agenda: Agenda }) => { new RewriteInvoicesJournalEntries(agenda); new OrganizationSetupJob(agenda); new OrganizationUpgrade(agenda); + new SendSaleInvoiceMailJob(agenda); + new SendSaleInvoiceReminderMailJob(agenda); + new SendSaleEstimateMailJob(agenda); + new SaleReceiptMailNotificationJob(agenda); + new PaymentReceiveMailNotificationJob(agenda); + new PlaidFetchTransactionsJob(agenda); agenda.start(); }; diff --git a/packages/server/src/loaders/tenantModels.ts b/packages/server/src/loaders/tenantModels.ts index 8640819d4..0608d5e94 100644 --- a/packages/server/src/loaders/tenantModels.ts +++ b/packages/server/src/loaders/tenantModels.ts @@ -62,6 +62,8 @@ import TaxRate from 'models/TaxRate'; import TaxRateTransaction from 'models/TaxRateTransaction'; import Attachment from 'models/Attachment'; import Import from 'models/Import'; +import PlaidItem from 'models/PlaidItem'; +import UncategorizedCashflowTransaction from 'models/UncategorizedCashflowTransaction'; export default (knex) => { const models = { @@ -126,7 +128,9 @@ export default (knex) => { TaxRate, TaxRateTransaction, Attachment, - Import + Import, + PlaidItem, + UncategorizedCashflowTransaction }; return mapValues(models, (model) => model.bindKnex(knex)); }; diff --git a/packages/server/src/models/Account.ts b/packages/server/src/models/Account.ts index 9d4fb053e..7e0d8d6e4 100644 --- a/packages/server/src/models/Account.ts +++ b/packages/server/src/models/Account.ts @@ -196,6 +196,7 @@ export default class Account extends mixin(TenantModel, [ const Expense = require('models/Expense'); const ExpenseEntry = require('models/ExpenseCategory'); const ItemEntry = require('models/ItemEntry'); + const UncategorizedTransaction = require('models/UncategorizedCashflowTransaction'); return { /** @@ -305,6 +306,21 @@ export default class Account extends mixin(TenantModel, [ to: 'items_entries.sellAccountId', }, }, + + /** + * Associated uncategorized transactions. + */ + uncategorizedTransactions: { + relation: Model.HasManyRelation, + modelClass: UncategorizedTransaction.default, + join: { + from: 'accounts.id', + to: 'uncategorized_cashflow_transactions.accountId', + }, + filter: (query) => { + query.where('categorized', false); + }, + }, }; } diff --git a/packages/server/src/models/CashflowTransaction.ts b/packages/server/src/models/CashflowTransaction.ts index 2184d1434..3cc2baba7 100644 --- a/packages/server/src/models/CashflowTransaction.ts +++ b/packages/server/src/models/CashflowTransaction.ts @@ -7,8 +7,14 @@ import { } from '@/services/Cashflow/utils'; import AccountTransaction from './AccountTransaction'; import { CASHFLOW_DIRECTION } from '@/services/Cashflow/constants'; - +import { getTransactionTypeLabel } from '@/utils/transactions-types'; export default class CashflowTransaction extends TenantModel { + transactionType: string; + amount: number; + exchangeRate: number; + uncategorize: boolean; + uncategorizedTransaction!: boolean; + /** * Table name. */ @@ -55,9 +61,10 @@ export default class CashflowTransaction extends TenantModel { /** * Transaction type formatted. + * @returns {string} */ get transactionTypeFormatted() { - return AccountTransaction.getReferenceTypeFormatted(this.transactionType); + return getTransactionTypeLabel(this.transactionType); } get typeMeta() { @@ -80,6 +87,14 @@ export default class CashflowTransaction extends TenantModel { return this.typeMeta?.direction === CASHFLOW_DIRECTION.IN; } + /** + * Detarmines whether the transaction imported from uncategorized transaction. + * @returns {boolean} + */ + get isCategroizedTranasction() { + return !!this.uncategorizedTransaction; + } + /** * Relationship mapping. */ diff --git a/packages/server/src/models/Contact.ts b/packages/server/src/models/Contact.ts index d63a2ea60..69661f639 100644 --- a/packages/server/src/models/Contact.ts +++ b/packages/server/src/models/Contact.ts @@ -2,6 +2,9 @@ import { Model } from 'objection'; import TenantModel from 'models/TenantModel'; export default class Contact extends TenantModel { + email: string; + displayName: string; + /** * Table name */ diff --git a/packages/server/src/models/Customer.ts b/packages/server/src/models/Customer.ts index 690b77d55..631763b71 100644 --- a/packages/server/src/models/Customer.ts +++ b/packages/server/src/models/Customer.ts @@ -24,6 +24,9 @@ export default class Customer extends mixin(TenantModel, [ CustomViewBaseModel, ModelSearchable, ]) { + email: string; + displayName: string; + /** * Query builder. */ @@ -76,6 +79,19 @@ export default class Customer extends mixin(TenantModel, [ return 'debit'; } + /** + * + */ + get contactAddresses() { + return [ + { + mail: this.email, + label: this.displayName, + primary: true + }, + ].filter((c) => c.mail); + } + /** * Model modifiers. */ diff --git a/packages/server/src/models/Expense.ts b/packages/server/src/models/Expense.ts index ed756e2bb..b2fce9a65 100644 --- a/packages/server/src/models/Expense.ts +++ b/packages/server/src/models/Expense.ts @@ -215,6 +215,10 @@ export default class Expense extends mixin(TenantModel, [ to: 'branches.id', }, }, + + /** + * + */ media: { relation: Model.ManyToManyRelation, modelClass: Media.default, diff --git a/packages/server/src/models/ExpenseCategory.ts b/packages/server/src/models/ExpenseCategory.ts index 50416805e..21d61f7e8 100644 --- a/packages/server/src/models/ExpenseCategory.ts +++ b/packages/server/src/models/ExpenseCategory.ts @@ -2,6 +2,8 @@ import { Model } from 'objection'; import TenantModel from 'models/TenantModel'; export default class ExpenseCategory extends TenantModel { + amount: number; + /** * Table name */ diff --git a/packages/server/src/models/PlaidItem.ts b/packages/server/src/models/PlaidItem.ts new file mode 100644 index 000000000..6dc515394 --- /dev/null +++ b/packages/server/src/models/PlaidItem.ts @@ -0,0 +1,24 @@ +import TenantModel from 'models/TenantModel'; + +export default class PlaidItem extends TenantModel { + /** + * Table name. + */ + static get tableName() { + return 'plaid_items'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + return {}; + } +} diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts new file mode 100644 index 000000000..928db9a4d --- /dev/null +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -0,0 +1,140 @@ +/* eslint-disable global-require */ +import TenantModel from 'models/TenantModel'; +import { Model, ModelOptions, QueryContext } from 'objection'; +import Account from './Account'; + +export default class UncategorizedCashflowTransaction extends TenantModel { + id!: number; + amount!: number; + categorized!: boolean; + accountId!: number; + + /** + * Table name. + */ + static get tableName() { + return 'uncategorized_cashflow_transactions'; + } + + /** + * Timestamps columns. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return [ + 'withdrawal', + 'deposit', + 'isDepositTransaction', + 'isWithdrawalTransaction', + ]; + } + + /** + * Retrieves the withdrawal amount. + * @returns {number} + */ + public get withdrawal() { + return this.amount < 0 ? Math.abs(this.amount) : 0; + } + + /** + * Retrieves the deposit amount. + * @returns {number} + */ + public get deposit(): number { + return this.amount > 0 ? Math.abs(this.amount) : 0; + } + + /** + * Detarmines whether the transaction is deposit transaction. + */ + public get isDepositTransaction(): boolean { + return 0 < this.deposit; + } + + /** + * Detarmines whether the transaction is withdrawal transaction. + */ + public get isWithdrawalTransaction(): boolean { + return 0 < this.withdrawal; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const Account = require('models/Account'); + + return { + /** + * Transaction may has associated to account. + */ + account: { + relation: Model.BelongsToOneRelation, + modelClass: Account.default, + join: { + from: 'uncategorized_cashflow_transactions.accountId', + to: 'accounts.id', + }, + }, + }; + } + + /** + * Updates the count of uncategorized transactions for the associated account + * based on the specified operation. + * @param {QueryContext} queryContext - The query context for the transaction. + * @param {boolean} increment - Indicates whether to increment or decrement the count. + */ + private async updateUncategorizedTransactionCount( + queryContext: QueryContext, + increment: boolean + ) { + const operation = increment ? 'increment' : 'decrement'; + const amount = increment ? 1 : -1; + + await Account.query(queryContext.transaction) + .findById(this.accountId) + [operation]('uncategorized_transactions', amount); + } + + /** + * Runs after insert. + * @param {QueryContext} queryContext + */ + public async $afterInsert(queryContext) { + await super.$afterInsert(queryContext); + await this.updateUncategorizedTransactionCount(queryContext, true); + } + + /** + * Runs after update. + * @param {ModelOptions} opt + * @param {QueryContext} queryContext + */ + public async $afterUpdate( + opt: ModelOptions, + queryContext: QueryContext + ): Promise { + await super.$afterUpdate(opt, queryContext); + + if (this.id && this.categorized) { + await this.updateUncategorizedTransactionCount(queryContext, false); + } + } + + /** + * Runs after delete. + * @param {QueryContext} queryContext + */ + public async $afterDelete(queryContext: QueryContext) { + await super.$afterDelete(queryContext); + await this.updateUncategorizedTransactionCount(queryContext, false); + } +} diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 2bbc9a789..9c08e6533 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -10,19 +10,6 @@ async function startServer() { // Intiialize all registered loaders. await loadersFactory({ expressApp: app }); - - app.listen(app.get('port'), (err) => { - if (err) { - console.log(err); - process.exit(1); - return; - } - console.log(` - ################################################ - Server listening on port: ${app.get('port')} - ################################################ - `); - }); } startServer(); diff --git a/packages/server/src/services/Accounts/AccountTransactionTransformer.ts b/packages/server/src/services/Accounts/AccountTransactionTransformer.ts index 857fe5ccc..d0e3e487f 100644 --- a/packages/server/src/services/Accounts/AccountTransactionTransformer.ts +++ b/packages/server/src/services/Accounts/AccountTransactionTransformer.ts @@ -1,6 +1,5 @@ import { IAccountTransaction } from '@/interfaces'; import { Transformer } from '@/lib/Transformer/Transformer'; -import { transaction } from 'objection'; export default class AccountTransactionTransformer extends Transformer { /** diff --git a/packages/server/src/services/Accounts/AccountTransform.ts b/packages/server/src/services/Accounts/AccountTransform.ts index 9297994be..cb58a9be9 100644 --- a/packages/server/src/services/Accounts/AccountTransform.ts +++ b/packages/server/src/services/Accounts/AccountTransform.ts @@ -13,7 +13,7 @@ export class AccountTransformer extends Transformer { * @returns {Array} */ public includeAttributes = (): string[] => { - return ['formattedAmount', 'flattenName']; + return ['formattedAmount', 'flattenName', 'bankBalanceFormatted']; }; /** @@ -34,13 +34,24 @@ export class AccountTransformer extends Transformer { /** * Retrieve formatted account amount. - * @param {IAccount} invoice + * @param {IAccount} invoice * @returns {string} */ protected formattedAmount = (account: IAccount): string => { return formatNumber(account.amount, { currencyCode: account.currencyCode }); }; + /** + * Retrieves the formatted bank balance. + * @param {IAccount} account + * @returns {string} + */ + protected bankBalanceFormatted = (account: IAccount): string => { + return formatNumber(account.bankBalance, { + currencyCode: account.currencyCode, + }); + }; + /** * Transformes the accounts collection to flat or nested array. * @param {IAccount[]} diff --git a/packages/server/src/services/Accounts/AccountsApplication.ts b/packages/server/src/services/Accounts/AccountsApplication.ts index 5e7043b9a..8182b3058 100644 --- a/packages/server/src/services/Accounts/AccountsApplication.ts +++ b/packages/server/src/services/Accounts/AccountsApplication.ts @@ -3,8 +3,10 @@ import { IAccount, IAccountCreateDTO, IAccountEditDTO, + IAccountResponse, IAccountsFilter, IAccountsTransactionsFilter, + IFilterMeta, IGetAccountTransactionPOJO, } from '@/interfaces'; import { CreateAccount } from './CreateAccount'; @@ -14,6 +16,7 @@ import { ActivateAccount } from './ActivateAccount'; import { GetAccounts } from './GetAccounts'; import { GetAccount } from './GetAccount'; import { GetAccountTransactions } from './GetAccountTransactions'; + @Service() export class AccountsApplication { @Inject() @@ -113,19 +116,22 @@ export class AccountsApplication { /** * Retrieves the accounts list. - * @param {number} tenantId - * @param {IAccountsFilter} filterDTO - * @returns + * @param {number} tenantId + * @param {IAccountsFilter} filterDTO + * @returns {Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }>} */ - public getAccounts = (tenantId: number, filterDTO: IAccountsFilter) => { + public getAccounts = ( + tenantId: number, + filterDTO: IAccountsFilter + ): Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }> => { return this.getAccountsService.getAccountsList(tenantId, filterDTO); }; /** * Retrieves the given account transactions. - * @param {number} tenantId - * @param {IAccountsTransactionsFilter} filter - * @returns {Promise} + * @param {number} tenantId + * @param {IAccountsTransactionsFilter} filter + * @returns {Promise} */ public getAccountsTransactions = ( tenantId: number, diff --git a/packages/server/src/services/Accounts/ActivateAccount.ts b/packages/server/src/services/Accounts/ActivateAccount.ts index 1fcd104f6..26afd836d 100644 --- a/packages/server/src/services/Accounts/ActivateAccount.ts +++ b/packages/server/src/services/Accounts/ActivateAccount.ts @@ -5,7 +5,6 @@ import { IAccountEventActivatedPayload } from '@/interfaces'; import events from '@/subscribers/events'; import UnitOfWork from '@/services/UnitOfWork'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; -import { CommandAccountValidators } from './CommandAccountValidators'; @Service() export class ActivateAccount { @@ -18,9 +17,6 @@ export class ActivateAccount { @Inject() private uow: UnitOfWork; - @Inject() - private validator: CommandAccountValidators; - /** * Activates/Inactivates the given account. * @param {number} tenantId diff --git a/packages/server/src/services/Banking/Plaid/PlaidApplication.ts b/packages/server/src/services/Banking/Plaid/PlaidApplication.ts new file mode 100644 index 000000000..5b6b5d827 --- /dev/null +++ b/packages/server/src/services/Banking/Plaid/PlaidApplication.ts @@ -0,0 +1,59 @@ +import { Inject, Service } from 'typedi'; +import { PlaidLinkTokenService } from './PlaidLinkToken'; +import { PlaidItemService } from './PlaidItem'; +import { PlaidItemDTO } from '@/interfaces'; +import { PlaidWebooks } from './PlaidWebhooks'; + +@Service() +export class PlaidApplication { + @Inject() + private getLinkTokenService: PlaidLinkTokenService; + + @Inject() + private plaidItemService: PlaidItemService; + + @Inject() + private plaidWebhooks: PlaidWebooks; + + /** + * Retrieves the Plaid link token. + * @param {number} tenantId + * @param {number} itemId + * @returns + */ + public getLinkToken(tenantId: number) { + return this.getLinkTokenService.getLinkToken(tenantId); + } + + /** + * Exchanges the Plaid access token. + * @param {number} tenantId + * @param {PlaidItemDTO} itemDTO + * @returns + */ + public exchangeToken(tenantId: number, itemDTO: PlaidItemDTO): Promise { + return this.plaidItemService.item(tenantId, itemDTO); + } + + /** + * Listens to Plaid webhooks + * @param {number} tenantId + * @param {string} webhookType + * @param {string} plaidItemId + * @param {string} webhookCode + * @returns + */ + public webhooks( + tenantId: number, + plaidItemId: string, + webhookType: string, + webhookCode: string + ) { + return this.plaidWebhooks.webhooks( + tenantId, + plaidItemId, + webhookType, + webhookCode + ); + } +} diff --git a/packages/server/src/services/Banking/Plaid/PlaidFetchTransactionsJob.ts b/packages/server/src/services/Banking/Plaid/PlaidFetchTransactionsJob.ts new file mode 100644 index 000000000..a397037c2 --- /dev/null +++ b/packages/server/src/services/Banking/Plaid/PlaidFetchTransactionsJob.ts @@ -0,0 +1,43 @@ +import Container, { Service } from 'typedi'; +import { PlaidUpdateTransactions } from './PlaidUpdateTransactions'; +import { IPlaidItemCreatedEventPayload } from '@/interfaces'; + +@Service() +export class PlaidFetchTransactionsJob { + /** + * Constructor method. + */ + constructor(agenda) { + agenda.define( + 'plaid-update-account-transactions', + { priority: 'high', concurrency: 2 }, + this.handler + ); + } + + /** + * Triggers the function. + */ + private handler = async (job, done: Function) => { + const { tenantId, plaidItemId } = job.attrs + .data as IPlaidItemCreatedEventPayload; + + const plaidFetchTransactionsService = Container.get( + PlaidUpdateTransactions + ); + const io = Container.get('socket'); + + try { + await plaidFetchTransactionsService.updateTransactions( + tenantId, + plaidItemId + ); + // Notify the frontend to reflect the new transactions changes. + io.emit('NEW_TRANSACTIONS_DATA', { plaidItemId }); + done(); + } catch (error) { + console.log(error); + done(error); + } + }; +} diff --git a/packages/server/src/services/Banking/Plaid/PlaidItem.ts b/packages/server/src/services/Banking/Plaid/PlaidItem.ts new file mode 100644 index 000000000..9e83202f9 --- /dev/null +++ b/packages/server/src/services/Banking/Plaid/PlaidItem.ts @@ -0,0 +1,58 @@ +import { Inject, Service } from 'typedi'; +import { PlaidClientWrapper } from '@/lib/Plaid'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; +import { + IPlaidItemCreatedEventPayload, + PlaidItemDTO, +} from '@/interfaces/Plaid'; +import SystemPlaidItem from '@/system/models/SystemPlaidItem'; + +@Service() +export class PlaidItemService { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + /** + * Exchanges the public token to get access token and item id and then creates + * a new Plaid item. + * @param {number} tenantId + * @param {PlaidItemDTO} itemDTO + * @returns {Promise} + */ + public async item(tenantId: number, itemDTO: PlaidItemDTO): Promise { + const { PlaidItem } = this.tenancy.models(tenantId); + const { publicToken, institutionId } = itemDTO; + + const plaidInstance = new PlaidClientWrapper(); + + // Exchange the public token for a private access token and store with the item. + const response = await plaidInstance.itemPublicTokenExchange({ + public_token: publicToken, + }); + const plaidAccessToken = response.data.access_token; + const plaidItemId = response.data.item_id; + + // Store the Plaid item metadata on tenant scope. + const plaidItem = await PlaidItem.query().insertAndFetch({ + tenantId, + plaidAccessToken, + plaidItemId, + plaidInstitutionId: institutionId, + }); + // Stores the Plaid item id on system scope. + await SystemPlaidItem.query().insert({ tenantId, plaidItemId }); + + // Triggers `onPlaidItemCreated` event. + await this.eventPublisher.emitAsync(events.plaid.onItemCreated, { + tenantId, + plaidAccessToken, + plaidItemId, + plaidInstitutionId: institutionId, + } as IPlaidItemCreatedEventPayload); + } +} diff --git a/packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts b/packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts new file mode 100644 index 000000000..89203df72 --- /dev/null +++ b/packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts @@ -0,0 +1,34 @@ +import { PlaidClientWrapper } from '@/lib/Plaid'; +import { Service } from 'typedi'; +import config from '@/config'; + +@Service() +export class PlaidLinkTokenService { + /** + * Retrieves the plaid link token. + * @param {number} tenantId + * @returns + */ + async getLinkToken(tenantId: number) { + const accessToken = null; + + // Must include transactions in order to receive transactions webhooks + const products = ['transactions']; + const linkTokenParams = { + user: { + // This should correspond to a unique id for the current user. + client_user_id: 'uniqueId' + tenantId, + }, + client_name: 'Pattern', + products, + country_codes: ['US'], + language: 'en', + webhook: config.plaid.linkWebhook, + access_token: accessToken, + }; + const plaidInstance = new PlaidClientWrapper(); + const createResponse = await plaidInstance.linkTokenCreate(linkTokenParams); + + return createResponse.data; + } +} diff --git a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts new file mode 100644 index 000000000..b3bf85ddc --- /dev/null +++ b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts @@ -0,0 +1,198 @@ +import * as R from 'ramda'; +import { Inject, Service } from 'typedi'; +import bluebird from 'bluebird'; +import { entries, groupBy } from 'lodash'; +import { CreateAccount } from '@/services/Accounts/CreateAccount'; +import { PlaidAccount, PlaidTransaction } from '@/interfaces'; +import { + transformPlaidAccountToCreateAccount, + transformPlaidTrxsToCashflowCreate, +} from './utils'; +import { DeleteCashflowTransaction } from '@/services/Cashflow/DeleteCashflowTransactionService'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; + +const CONCURRENCY_ASYNC = 10; + +@Service() +export class PlaidSyncDb { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private createAccountService: CreateAccount; + + @Inject() + private cashflowApp: CashflowApplication; + + @Inject() + private deleteCashflowTransactionService: DeleteCashflowTransaction; + + /** + * Syncs the plaid accounts to the system accounts. + * @param {number} tenantId Tenant ID. + * @param {PlaidAccount[]} plaidAccounts + * @returns {Promise} + */ + public async syncBankAccounts( + tenantId: number, + plaidAccounts: PlaidAccount[], + institution: any + ): Promise { + const transformToPlaidAccounts = + transformPlaidAccountToCreateAccount(institution); + + const accountCreateDTOs = R.map(transformToPlaidAccounts)(plaidAccounts); + + await bluebird.map( + accountCreateDTOs, + (createAccountDTO: any) => + this.createAccountService.createAccount(tenantId, createAccountDTO), + { concurrency: CONCURRENCY_ASYNC } + ); + } + + /** + * Synsc the Plaid transactions to the system GL entries. + * @param {number} tenantId - Tenant ID. + * @param {number} plaidAccountId - Plaid account ID. + * @param {PlaidTransaction[]} plaidTranasctions - Plaid transactions + */ + public async syncAccountTranactions( + tenantId: number, + plaidAccountId: number, + plaidTranasctions: PlaidTransaction[] + ): Promise { + const { Account } = this.tenancy.models(tenantId); + + const cashflowAccount = await Account.query() + .findOne({ plaidAccountId }) + .throwIfNotFound(); + + const openingEquityBalance = await Account.query().findOne( + 'slug', + 'opening-balance-equity' + ); + // Transformes the Plaid transactions to cashflow create DTOs. + const transformTransaction = transformPlaidTrxsToCashflowCreate( + cashflowAccount.id, + openingEquityBalance.id + ); + const uncategorizedTransDTOs = + R.map(transformTransaction)(plaidTranasctions); + + // Creating account transaction queue. + await bluebird.map( + uncategorizedTransDTOs, + (uncategoriedDTO) => + this.cashflowApp.createUncategorizedTransaction( + tenantId, + uncategoriedDTO + ), + { concurrency: 1 } + ); + } + + /** + * Syncs the accounts transactions in paraller under controlled concurrency. + * @param {number} tenantId + * @param {PlaidTransaction[]} plaidTransactions + */ + public async syncAccountsTransactions( + tenantId: number, + plaidAccountsTransactions: PlaidTransaction[] + ): Promise { + const groupedTrnsxByAccountId = entries( + groupBy(plaidAccountsTransactions, 'account_id') + ); + await bluebird.map( + groupedTrnsxByAccountId, + ([plaidAccountId, plaidTransactions]: [number, PlaidTransaction[]]) => { + return this.syncAccountTranactions( + tenantId, + plaidAccountId, + plaidTransactions + ); + }, + { concurrency: CONCURRENCY_ASYNC } + ); + } + + /** + * Syncs the removed Plaid transactions ids from the cashflow system transactions. + * @param {string[]} plaidTransactionsIds - Plaid Transactions IDs. + */ + public async syncRemoveTransactions( + tenantId: number, + plaidTransactionsIds: string[] + ) { + const { CashflowTransaction } = this.tenancy.models(tenantId); + + const cashflowTransactions = await CashflowTransaction.query().whereIn( + 'plaidTransactionId', + plaidTransactionsIds + ); + const cashflowTransactionsIds = cashflowTransactions.map( + (trans) => trans.id + ); + await bluebird.map( + cashflowTransactionsIds, + (transactionId: number) => + this.deleteCashflowTransactionService.deleteCashflowTransaction( + tenantId, + transactionId + ), + { concurrency: CONCURRENCY_ASYNC } + ); + } + + /** + * Syncs the Plaid item last transaction cursor. + * @param {number} tenantId - Tenant ID. + * @param {string} itemId - Plaid item ID. + * @param {string} lastCursor - Last transaction cursor. + */ + public async syncTransactionsCursor( + tenantId: number, + plaidItemId: string, + lastCursor: string + ) { + const { PlaidItem } = this.tenancy.models(tenantId); + + await PlaidItem.query().findOne({ plaidItemId }).patch({ lastCursor }); + } + + /** + * Updates the last feeds updated at of the given Plaid accounts ids. + * @param {number} tenantId + * @param {string[]} plaidAccountIds + */ + public async updateLastFeedsUpdatedAt( + tenantId: number, + plaidAccountIds: string[] + ) { + const { Account } = this.tenancy.models(tenantId); + + await Account.query().whereIn('plaid_account_id', plaidAccountIds).patch({ + lastFeedsUpdatedAt: new Date(), + }); + } + + /** + * Updates the accounts feed active status of the given Plaid accounts ids. + * @param {number} tenantId + * @param {number[]} plaidAccountIds + * @param {boolean} isFeedsActive + */ + public async updateAccountsFeedsActive( + tenantId: number, + plaidAccountIds: string[], + isFeedsActive: boolean = true + ) { + const { Account } = this.tenancy.models(tenantId); + + await Account.query().whereIn('plaid_account_id', plaidAccountIds).patch({ + isFeedsActive, + }); + } +} diff --git a/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts b/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts new file mode 100644 index 000000000..c740e4705 --- /dev/null +++ b/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts @@ -0,0 +1,119 @@ +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { Inject, Service } from 'typedi'; +import { PlaidClientWrapper } from '@/lib/Plaid/Plaid'; +import { PlaidSyncDb } from './PlaidSyncDB'; +import { PlaidFetchedTransactionsUpdates } from '@/interfaces'; + +@Service() +export class PlaidUpdateTransactions { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private plaidSync: PlaidSyncDb; + + /** + * Handles the fetching and storing of new, modified, or removed transactions + * @param {number} tenantId Tenant ID. + * @param {string} plaidItemId the Plaid ID for the item. + */ + public async updateTransactions(tenantId: number, plaidItemId: string) { + // Fetch new transactions from plaid api. + const { added, modified, removed, cursor, accessToken } = + await this.fetchTransactionUpdates(tenantId, plaidItemId); + + const request = { access_token: accessToken }; + const plaidInstance = new PlaidClientWrapper(); + const { + data: { accounts, item }, + } = await plaidInstance.accountsGet(request); + + const plaidAccountsIds = accounts.map((a) => a.account_id); + + const { + data: { institution }, + } = await plaidInstance.institutionsGetById({ + institution_id: item.institution_id, + country_codes: ['US', 'UK'], + }); + // Update the DB. + await this.plaidSync.syncBankAccounts(tenantId, accounts, institution); + await this.plaidSync.syncAccountsTransactions( + tenantId, + added.concat(modified) + ); + await this.plaidSync.syncRemoveTransactions(tenantId, removed); + await this.plaidSync.syncTransactionsCursor(tenantId, plaidItemId, cursor); + + // Update the last feeds updated at of the updated accounts. + await this.plaidSync.updateLastFeedsUpdatedAt(tenantId, plaidAccountsIds); + + // Turn on the accounts feeds flag. + await this.plaidSync.updateAccountsFeedsActive(tenantId, plaidAccountsIds); + + return { + addedCount: added.length, + modifiedCount: modified.length, + removedCount: removed.length, + }; + } + + /** + * Fetches transactions from the `Plaid API` for a given item. + * @param {number} tenantId - Tenant ID. + * @param {string} plaidItemId - The Plaid ID for the item. + * @returns {Promise} + */ + private async fetchTransactionUpdates( + tenantId: number, + plaidItemId: string + ): Promise { + // the transactions endpoint is paginated, so we may need to hit it multiple times to + // retrieve all available transactions. + const { PlaidItem } = this.tenancy.models(tenantId); + + const plaidItem = await PlaidItem.query().findOne( + 'plaidItemId', + plaidItemId + ); + if (!plaidItem) { + throw new Error('The given Plaid item id is not found.'); + } + const { plaidAccessToken, lastCursor } = plaidItem; + let cursor = lastCursor; + + // New transaction updates since "cursor" + let added = []; + let modified = []; + // Removed transaction ids + let removed = []; + let hasMore = true; + + const batchSize = 100; + try { + // Iterate through each page of new transaction updates for item + /* eslint-disable no-await-in-loop */ + while (hasMore) { + const request = { + access_token: plaidAccessToken, + cursor: cursor, + count: batchSize, + }; + const plaidInstance = new PlaidClientWrapper(); + const response = await plaidInstance.transactionsSync(request); + const data = response.data; + // Add this page of results + added = added.concat(data.added); + modified = modified.concat(data.modified); + removed = removed.concat(data.removed); + hasMore = data.has_more; + // Update cursor to the next cursor + cursor = data.next_cursor; + } + } catch (err) { + console.error(`Error fetching transactions: ${err.message}`); + cursor = lastCursor; + } + return { added, modified, removed, cursor, accessToken: plaidAccessToken }; + } +} diff --git a/packages/server/src/services/Banking/Plaid/PlaidWebhookTenantBootMiddleware.ts b/packages/server/src/services/Banking/Plaid/PlaidWebhookTenantBootMiddleware.ts new file mode 100644 index 000000000..6a1c177ba --- /dev/null +++ b/packages/server/src/services/Banking/Plaid/PlaidWebhookTenantBootMiddleware.ts @@ -0,0 +1,32 @@ +import { Request, Response, NextFunction } from 'express'; +import { SystemPlaidItem, Tenant } from '@/system/models'; +import tenantDependencyInjection from '@/api/middleware/TenantDependencyInjection'; + +export const PlaidWebhookTenantBootMiddleware = async ( + req: Request, + res: Response, + next: NextFunction +) => { + const { item_id: plaidItemId } = req.body; + const plaidItem = await SystemPlaidItem.query().findOne({ plaidItemId }); + + const notFoundOrganization = () => { + return res.boom.unauthorized('Organization identication not found.', { + errors: [{ type: 'ORGANIZATION.ID.NOT.FOUND', code: 100 }], + }); + }; + // In case the given organization not found. + if (!plaidItem) { + return notFoundOrganization(); + } + const tenant = await Tenant.query() + .findById(plaidItem.tenantId) + .withGraphFetched('metadata'); + + // When the given organization id not found on the system storage. + if (!tenant) { + return notFoundOrganization(); + } + tenantDependencyInjection(req, tenant); + next(); +}; diff --git a/packages/server/src/services/Banking/Plaid/PlaidWebhooks.ts b/packages/server/src/services/Banking/Plaid/PlaidWebhooks.ts new file mode 100644 index 000000000..5c3afb1ec --- /dev/null +++ b/packages/server/src/services/Banking/Plaid/PlaidWebhooks.ts @@ -0,0 +1,140 @@ +import { Inject, Service } from 'typedi'; +import { PlaidUpdateTransactions } from './PlaidUpdateTransactions'; + +@Service() +export class PlaidWebooks { + @Inject() + private updateTransactionsService: PlaidUpdateTransactions; + + /** + * Listens to Plaid webhooks + * @param {number} tenantId - Tenant Id. + * @param {string} webhookType - Webhook type. + * @param {string} plaidItemId - Plaid item Id. + * @param {string} webhookCode - webhook code. + */ + public async webhooks( + tenantId: number, + plaidItemId: string, + webhookType: string, + webhookCode: string + ): Promise { + const _webhookType = webhookType.toLowerCase(); + + // There are five types of webhooks: AUTH, TRANSACTIONS, ITEM, INCOME, and ASSETS. + // @TODO implement handling for remaining webhook types. + const webhookHandlerMap = { + transactions: this.handleTransactionsWebooks.bind(this), + item: this.itemsHandler.bind(this), + }; + const webhookHandler = + webhookHandlerMap[_webhookType] || this.unhandledWebhook; + + await webhookHandler(tenantId, plaidItemId, webhookCode); + } + + /** + * Handles all unhandled/not yet implemented webhook events. + * @param {string} webhookType + * @param {string} webhookCode + * @param {string} plaidItemId + */ + private async unhandledWebhook( + webhookType: string, + webhookCode: string, + plaidItemId: string + ): Promise { + console.log( + `UNHANDLED ${webhookType} WEBHOOK: ${webhookCode}: Plaid item id ${plaidItemId}: unhandled webhook type received.` + ); + } + + /** + * Logs to console and emits to socket + * @param {string} additionalInfo + * @param {string} webhookCode + * @param {string} plaidItemId + */ + private serverLogAndEmitSocket( + additionalInfo: string, + webhookCode: string, + plaidItemId: string + ): void { + console.log( + `WEBHOOK: TRANSACTIONS: ${webhookCode}: Plaid_item_id ${plaidItemId}: ${additionalInfo}` + ); + } + + /** + * Handles all transaction webhook events. The transaction webhook notifies + * you that a single item has new transactions available. + * @param {number} tenantId + * @param {string} plaidItemId + * @param {string} webhookCode + * @returns {Promise} + */ + public async handleTransactionsWebooks( + tenantId: number, + plaidItemId: string, + webhookCode: string + ): Promise { + switch (webhookCode) { + case 'SYNC_UPDATES_AVAILABLE': { + // Fired when new transactions data becomes available. + const { addedCount, modifiedCount, removedCount } = + await this.updateTransactionsService.updateTransactions( + tenantId, + plaidItemId + ); + this.serverLogAndEmitSocket( + `Transactions: ${addedCount} added, ${modifiedCount} modified, ${removedCount} removed`, + webhookCode, + plaidItemId + ); + break; + } + case 'DEFAULT_UPDATE': + case 'INITIAL_UPDATE': + case 'HISTORICAL_UPDATE': + /* ignore - not needed if using sync endpoint + webhook */ + break; + default: + this.serverLogAndEmitSocket( + `unhandled webhook type received.`, + webhookCode, + plaidItemId + ); + } + } + + /** + * Handles all Item webhook events. + * @param {number} tenantId - Tenant ID + * @param {string} webhookCode - The webhook code + * @param {string} plaidItemId - The Plaid ID for the item + * @returns {Promise} + */ + public async itemsHandler( + tenantId: number, + plaidItemId: string, + webhookCode: string + ): Promise { + switch (webhookCode) { + case 'WEBHOOK_UPDATE_ACKNOWLEDGED': + this.serverLogAndEmitSocket('is updated', plaidItemId, error); + break; + case 'ERROR': { + break; + } + case 'PENDING_EXPIRATION': { + break; + } + default: + this.serverLogAndEmitSocket( + 'unhandled webhook type received.', + webhookCode, + plaidItemId + ); + } + } +} diff --git a/packages/server/src/services/Banking/Plaid/subscribers/PlaidUpdateTransactionsOnItemCreatedSubscriber.ts b/packages/server/src/services/Banking/Plaid/subscribers/PlaidUpdateTransactionsOnItemCreatedSubscriber.ts new file mode 100644 index 000000000..216eae2f2 --- /dev/null +++ b/packages/server/src/services/Banking/Plaid/subscribers/PlaidUpdateTransactionsOnItemCreatedSubscriber.ts @@ -0,0 +1,34 @@ +import { Inject, Service } from 'typedi'; +import { EventSubscriber } from '@/lib/EventPublisher/EventPublisher'; +import { IPlaidItemCreatedEventPayload } from '@/interfaces/Plaid'; +import events from '@/subscribers/events'; + +@Service() +export class PlaidUpdateTransactionsOnItemCreatedSubscriber extends EventSubscriber { + @Inject('agenda') + private agenda: any; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.plaid.onItemCreated, + this.handleUpdateTransactionsOnItemCreated + ); + } + + /** + * Updates the Plaid item transactions + * @param {IPlaidItemCreatedEventPayload} payload - Event payload. + */ + private handleUpdateTransactionsOnItemCreated = async ({ + tenantId, + plaidItemId, + plaidAccessToken, + plaidInstitutionId, + }: IPlaidItemCreatedEventPayload) => { + const payload = { tenantId, plaidItemId }; + await this.agenda.now('plaid-update-account-transactions', payload); + }; +} diff --git a/packages/server/src/services/Banking/Plaid/utils.ts b/packages/server/src/services/Banking/Plaid/utils.ts new file mode 100644 index 000000000..c8a3cf528 --- /dev/null +++ b/packages/server/src/services/Banking/Plaid/utils.ts @@ -0,0 +1,54 @@ +import * as R from 'ramda'; +import { + CreateUncategorizedTransactionDTO, + IAccountCreateDTO, + PlaidAccount, + PlaidTransaction, +} from '@/interfaces'; + +/** + * Transformes the Plaid account to create cashflow account DTO. + * @param {PlaidAccount} plaidAccount + * @returns {IAccountCreateDTO} + */ +export const transformPlaidAccountToCreateAccount = R.curry( + (institution: any, plaidAccount: PlaidAccount): IAccountCreateDTO => { + return { + name: `${institution.name} - ${plaidAccount.name}`, + code: '', + description: plaidAccount.official_name, + currencyCode: plaidAccount.balances.iso_currency_code, + accountType: 'cash', + active: true, + plaidAccountId: plaidAccount.account_id, + bankBalance: plaidAccount.balances.current, + accountMask: plaidAccount.mask, + }; + } +); + +/** + * Transformes the plaid transaction to cashflow create DTO. + * @param {number} cashflowAccountId - Cashflow account ID. + * @param {number} creditAccountId - Credit account ID. + * @param {PlaidTransaction} plaidTranasction - Plaid transaction. + * @returns {CreateUncategorizedTransactionDTO} + */ +export const transformPlaidTrxsToCashflowCreate = R.curry( + ( + cashflowAccountId: number, + creditAccountId: number, + plaidTranasction: PlaidTransaction + ): CreateUncategorizedTransactionDTO => { + return { + date: plaidTranasction.date, + amount: plaidTranasction.amount, + description: plaidTranasction.name, + payee: plaidTranasction.payment_meta?.payee, + currencyCode: plaidTranasction.iso_currency_code, + accountId: cashflowAccountId, + referenceNo: plaidTranasction.payment_meta?.reference_number, + plaidTransactionId: plaidTranasction.transaction_id, + }; + } +); diff --git a/packages/server/src/services/Cashflow/CashflowApplication.ts b/packages/server/src/services/Cashflow/CashflowApplication.ts new file mode 100644 index 000000000..6688c9016 --- /dev/null +++ b/packages/server/src/services/Cashflow/CashflowApplication.ts @@ -0,0 +1,213 @@ +import { Inject, Service } from 'typedi'; +import { DeleteCashflowTransaction } from './DeleteCashflowTransactionService'; +import { UncategorizeCashflowTransaction } from './UncategorizeCashflowTransaction'; +import { CategorizeCashflowTransaction } from './CategorizeCashflowTransaction'; +import { + CategorizeTransactionAsExpenseDTO, + CreateUncategorizedTransactionDTO, + ICashflowAccountsFilter, + ICashflowNewCommandDTO, + ICategorizeCashflowTransactioDTO, + IGetUncategorizedTransactionsQuery, +} from '@/interfaces'; +import { CategorizeTransactionAsExpense } from './CategorizeTransactionAsExpense'; +import { GetUncategorizedTransactions } from './GetUncategorizedTransactions'; +import { CreateUncategorizedTransaction } from './CreateUncategorizedTransaction'; +import { GetUncategorizedTransaction } from './GetUncategorizedTransaction'; +import NewCashflowTransactionService from './NewCashflowTransactionService'; +import GetCashflowAccountsService from './GetCashflowAccountsService'; +import { GetCashflowTransactionService } from './GetCashflowTransactionsService'; + +@Service() +export class CashflowApplication { + @Inject() + private createTransactionService: NewCashflowTransactionService; + + @Inject() + private deleteTransactionService: DeleteCashflowTransaction; + + @Inject() + private getCashflowAccountsService: GetCashflowAccountsService; + + @Inject() + private getCashflowTransactionService: GetCashflowTransactionService; + + @Inject() + private uncategorizeTransactionService: UncategorizeCashflowTransaction; + + @Inject() + private categorizeTransactionService: CategorizeCashflowTransaction; + + @Inject() + private categorizeAsExpenseService: CategorizeTransactionAsExpense; + + @Inject() + private getUncategorizedTransactionsService: GetUncategorizedTransactions; + + @Inject() + private getUncategorizedTransactionService: GetUncategorizedTransaction; + + @Inject() + private createUncategorizedTransactionService: CreateUncategorizedTransaction; + + /** + * Creates a new cashflow transaction. + * @param {number} tenantId + * @param {ICashflowNewCommandDTO} transactionDTO + * @param {number} userId + * @returns + */ + public createTransaction( + tenantId: number, + transactionDTO: ICashflowNewCommandDTO, + userId?: number + ) { + return this.createTransactionService.newCashflowTransaction( + tenantId, + transactionDTO, + userId + ); + } + + /** + * Deletes the given cashflow transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @returns + */ + public deleteTransaction(tenantId: number, cashflowTransactionId: number) { + return this.deleteTransactionService.deleteCashflowTransaction( + tenantId, + cashflowTransactionId + ); + } + + /** + * Retrieves specific cashflow transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @returns + */ + public getTransaction(tenantId: number, cashflowTransactionId: number) { + return this.getCashflowTransactionService.getCashflowTransaction( + tenantId, + cashflowTransactionId + ); + } + + /** + * Retrieves the cashflow accounts. + * @param {number} tenantId + * @param {ICashflowAccountsFilter} filterDTO + * @returns + */ + public getCashflowAccounts( + tenantId: number, + filterDTO: ICashflowAccountsFilter + ) { + return this.getCashflowAccountsService.getCashflowAccounts( + tenantId, + filterDTO + ); + } + + /** + * Creates a new uncategorized cash transaction. + * @param {number} tenantId + * @param {CreateUncategorizedTransactionDTO} createUncategorizedTransactionDTO + * @returns {IUncategorizedCashflowTransaction} + */ + public createUncategorizedTransaction( + tenantId: number, + createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO + ) { + return this.createUncategorizedTransactionService.create( + tenantId, + createUncategorizedTransactionDTO + ); + } + + /** + * Uncategorize the given cashflow transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @returns + */ + public uncategorizeTransaction( + tenantId: number, + cashflowTransactionId: number + ) { + return this.uncategorizeTransactionService.uncategorize( + tenantId, + cashflowTransactionId + ); + } + + /** + * Categorize the given cashflow transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @param {ICategorizeCashflowTransactioDTO} categorizeDTO + * @returns + */ + public categorizeTransaction( + tenantId: number, + cashflowTransactionId: number, + categorizeDTO: ICategorizeCashflowTransactioDTO + ) { + return this.categorizeTransactionService.categorize( + tenantId, + cashflowTransactionId, + categorizeDTO + ); + } + + /** + * Categorizes the given cashflow transaction as expense transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @param {CategorizeTransactionAsExpenseDTO} transactionDTO + */ + public categorizeAsExpense( + tenantId: number, + cashflowTransactionId: number, + transactionDTO: CategorizeTransactionAsExpenseDTO + ) { + return this.categorizeAsExpenseService.categorize( + tenantId, + cashflowTransactionId, + transactionDTO + ); + } + + /** + * Retrieves the uncategorized cashflow transactions. + * @param {number} tenantId + */ + public getUncategorizedTransactions( + tenantId: number, + accountId: number, + query: IGetUncategorizedTransactionsQuery + ) { + return this.getUncategorizedTransactionsService.getTransactions( + tenantId, + accountId, + query + ); + } + + /** + * Retrieves specific uncategorized transaction. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + */ + public getUncategorizedTransaction( + tenantId: number, + uncategorizedTransactionId: number + ) { + return this.getUncategorizedTransactionService.getTransaction( + tenantId, + uncategorizedTransactionId + ); + } +} diff --git a/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts b/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts index 21df84f34..c1c590d55 100644 --- a/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts +++ b/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts @@ -1,11 +1,9 @@ import { Inject, Service } from 'typedi'; import { Knex } from 'knex'; -import * as R from 'ramda'; import { ILedgerEntry, ICashflowTransaction, AccountNormal, - ICashflowTransactionLine, } from '../../interfaces'; import { transformCashflowTransactionType, diff --git a/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts b/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts new file mode 100644 index 000000000..3d19e1547 --- /dev/null +++ b/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts @@ -0,0 +1,101 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import events from '@/subscribers/events'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import UnitOfWork from '../UnitOfWork'; +import { + ICashflowTransactionCategorizedPayload, + ICashflowTransactionUncategorizingPayload, + ICategorizeCashflowTransactioDTO, +} from '@/interfaces'; +import { Knex } from 'knex'; +import { transformCategorizeTransToCashflow } from './utils'; +import { CommandCashflowValidator } from './CommandCasflowValidator'; +import NewCashflowTransactionService from './NewCashflowTransactionService'; +import { TransferAuthorizationGuaranteeDecision } from 'plaid'; + +@Service() +export class CategorizeCashflowTransaction { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private commandValidators: CommandCashflowValidator; + + @Inject() + private createCashflow: NewCashflowTransactionService; + + /** + * Categorize the given cashflow transaction. + * @param {number} tenantId + * @param {ICategorizeCashflowTransactioDTO} categorizeDTO + */ + public async categorize( + tenantId: number, + uncategorizedTransactionId: number, + categorizeDTO: ICategorizeCashflowTransactioDTO + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + // Retrieves the uncategorized transaction or throw an error. + const transaction = await UncategorizedCashflowTransaction.query() + .findById(uncategorizedTransactionId) + .throwIfNotFound(); + + // Validates the transaction shouldn't be categorized before. + this.commandValidators.validateTransactionShouldNotCategorized(transaction); + + // Validate the uncateogirzed transaction if it's deposit the transaction direction + // should `IN` and the same thing if it's withdrawal the direction should be OUT. + this.commandValidators.validateUncategorizeTransactionType( + transaction, + categorizeDTO.transactionType + ); + // Edits the cashflow transaction under UOW env. + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onTransactionCategorizing` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCategorizing, + { + tenantId, + trx, + } as ICashflowTransactionUncategorizingPayload + ); + // Transformes the categorize DTO to the cashflow transaction. + const cashflowTransactionDTO = transformCategorizeTransToCashflow( + transaction, + categorizeDTO + ); + // Creates a new cashflow transaction. + const cashflowTransaction = + await this.createCashflow.newCashflowTransaction( + tenantId, + cashflowTransactionDTO + ); + // Updates the uncategorized transaction as categorized. + await UncategorizedCashflowTransaction.query(trx).patchAndFetchById( + uncategorizedTransactionId, + { + categorized: true, + categorizeRefType: 'CashflowTransaction', + categorizeRefId: cashflowTransaction.id, + } + ); + // Triggers `onCashflowTransactionCategorized` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCategorized, + { + tenantId, + // cashflowTransaction, + trx, + } as ICashflowTransactionCategorizedPayload + ); + }); + } +} diff --git a/packages/server/src/services/Cashflow/CategorizeTransactionAsExpense.ts b/packages/server/src/services/Cashflow/CategorizeTransactionAsExpense.ts new file mode 100644 index 000000000..119f0cc7b --- /dev/null +++ b/packages/server/src/services/Cashflow/CategorizeTransactionAsExpense.ts @@ -0,0 +1,80 @@ +import { + CategorizeTransactionAsExpenseDTO, + ICashflowTransactionCategorizedPayload, +} from '@/interfaces'; +import { Inject, Service } from 'typedi'; +import UnitOfWork from '../UnitOfWork'; +import events from '@/subscribers/events'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { Knex } from 'knex'; +import { CreateExpense } from '../Expenses/CRUD/CreateExpense'; + +@Service() +export class CategorizeTransactionAsExpense { + @Inject() + private uow: UnitOfWork; + + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private createExpenseService: CreateExpense; + + /** + * Categorize the transaction as expense transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @param {CategorizeTransactionAsExpenseDTO} transactionDTO + */ + public async categorize( + tenantId: number, + cashflowTransactionId: number, + transactionDTO: CategorizeTransactionAsExpenseDTO + ) { + const { CashflowTransaction } = this.tenancy.models(tenantId); + + const transaction = await CashflowTransaction.query() + .findById(cashflowTransactionId) + .throwIfNotFound(); + + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onTransactionUncategorizing` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCategorizingAsExpense, + { + tenantId, + trx, + } as ICashflowTransactionCategorizedPayload + ); + // Creates a new expense transaction. + const expenseTransaction = await this.createExpenseService.newExpense( + tenantId, + { + + }, + 1 + ); + // Updates the item on the storage and fetches the updated once. + const cashflowTransaction = await CashflowTransaction.query( + trx + ).patchAndFetchById(cashflowTransactionId, { + categorizeRefType: 'Expense', + categorizeRefId: expenseTransaction.id, + uncategorized: true, + }); + // Triggers `onTransactionUncategorized` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCategorizedAsExpense, + { + tenantId, + cashflowTransaction, + trx, + } as ICashflowTransactionUncategorizedPayload + ); + }); + } +} diff --git a/packages/server/src/services/Cashflow/CommandCasflowValidator.ts b/packages/server/src/services/Cashflow/CommandCasflowValidator.ts index 07722e50e..7a6c5c973 100644 --- a/packages/server/src/services/Cashflow/CommandCasflowValidator.ts +++ b/packages/server/src/services/Cashflow/CommandCasflowValidator.ts @@ -1,9 +1,14 @@ import { Service } from 'typedi'; import { includes, camelCase, upperFirst } from 'lodash'; -import { IAccount } from '@/interfaces'; +import { IAccount, IUncategorizedCashflowTransaction } from '@/interfaces'; import { getCashflowTransactionType } from './utils'; import { ServiceError } from '@/exceptions'; -import { CASHFLOW_TRANSACTION_TYPE, ERRORS } from './constants'; +import { + CASHFLOW_DIRECTION, + CASHFLOW_TRANSACTION_TYPE, + ERRORS, +} from './constants'; +import CashflowTransaction from '@/models/CashflowTransaction'; @Service() export class CommandCashflowValidator { @@ -46,4 +51,52 @@ export class CommandCashflowValidator { } return transformedType; }; + + /** + * Validate the given transaction should be categorized. + * @param {CashflowTransaction} cashflowTransaction + */ + public validateTransactionShouldCategorized( + cashflowTransaction: CashflowTransaction + ) { + if (!cashflowTransaction.uncategorize) { + throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED); + } + } + + /** + * Validate the given transcation shouldn't be categorized. + * @param {CashflowTransaction} cashflowTransaction + */ + public validateTransactionShouldNotCategorized( + cashflowTransaction: CashflowTransaction + ) { + if (cashflowTransaction.uncategorize) { + throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED); + } + } + + /** + * + * @param {uncategorizeTransaction} + * @param {string} transactionType + * @throws {ServiceError(ERRORS.UNCATEGORIZED_TRANSACTION_TYPE_INVALID)} + */ + public validateUncategorizeTransactionType( + uncategorizeTransaction: IUncategorizedCashflowTransaction, + transactionType: string + ) { + const type = getCashflowTransactionType( + upperFirst(camelCase(transactionType)) as CASHFLOW_TRANSACTION_TYPE + ); + if ( + (type.direction === CASHFLOW_DIRECTION.IN && + uncategorizeTransaction.isDepositTransaction) || + (type.direction === CASHFLOW_DIRECTION.OUT && + uncategorizeTransaction.isWithdrawalTransaction) + ) { + return; + } + throw new ServiceError(ERRORS.UNCATEGORIZED_TRANSACTION_TYPE_INVALID); + } } diff --git a/packages/server/src/services/Cashflow/CreateUncategorizedTransaction.ts b/packages/server/src/services/Cashflow/CreateUncategorizedTransaction.ts new file mode 100644 index 000000000..ccb2aca25 --- /dev/null +++ b/packages/server/src/services/Cashflow/CreateUncategorizedTransaction.ts @@ -0,0 +1,40 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import UnitOfWork, { IsolationLevel } from '../UnitOfWork'; +import { Knex } from 'knex'; +import { CreateUncategorizedTransactionDTO } from '@/interfaces'; + +@Service() +export class CreateUncategorizedTransaction { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + /** + * Creates an uncategorized cashflow transaction. + * @param {number} tenantId + * @param {CreateUncategorizedTransactionDTO} createDTO + */ + public create( + tenantId: number, + createDTO: CreateUncategorizedTransactionDTO + ) { + const { UncategorizedCashflowTransaction, Account } = + this.tenancy.models(tenantId); + + return this.uow.withTransaction( + tenantId, + async (trx: Knex.Transaction) => { + const transaction = await UncategorizedCashflowTransaction.query( + trx + ).insertAndFetch({ + ...createDTO, + }); + + return transaction; + }, + ); + } +} diff --git a/packages/server/src/services/Cashflow/DeleteCashflowTransactionService.ts b/packages/server/src/services/Cashflow/DeleteCashflowTransactionService.ts index e07073c3d..dd6c3002a 100644 --- a/packages/server/src/services/Cashflow/DeleteCashflowTransactionService.ts +++ b/packages/server/src/services/Cashflow/DeleteCashflowTransactionService.ts @@ -13,15 +13,15 @@ import UnitOfWork from '@/services/UnitOfWork'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; @Service() -export default class CommandCashflowTransactionService { +export class DeleteCashflowTransaction { @Inject() - tenancy: HasTenancyService; + private tenancy: HasTenancyService; @Inject() - eventPublisher: EventPublisher; + private eventPublisher: EventPublisher; @Inject() - uow: UnitOfWork; + private uow: UnitOfWork; /** * Deletes the cashflow transaction with associated journal entries. diff --git a/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts b/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts index eb22dd305..64afd2194 100644 --- a/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts +++ b/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts @@ -4,17 +4,13 @@ import { CashflowTransactionTransformer } from './CashflowTransactionTransformer import { ERRORS } from './constants'; import { ICashflowTransaction } from '@/interfaces'; import { ServiceError } from '@/exceptions'; -import I18nService from '@/services/I18n/I18nService'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; @Service() -export default class GetCashflowTransactionsService { +export class GetCashflowTransactionService { @Inject() private tenancy: HasTenancyService; - @Inject() - private i18nService: I18nService; - @Inject() private transfromer: TransformerInjectable; @@ -35,6 +31,7 @@ export default class GetCashflowTransactionsService { .withGraphFetched('entries.cashflowAccount') .withGraphFetched('entries.creditAccount') .withGraphFetched('transactions.account') + .orderBy('date', 'DESC') .throwIfNotFound(); this.throwErrorCashflowTranscationNotFound(cashflowTransaction); diff --git a/packages/server/src/services/Cashflow/GetUncategorizedTransaction.ts b/packages/server/src/services/Cashflow/GetUncategorizedTransaction.ts new file mode 100644 index 000000000..82cf531a8 --- /dev/null +++ b/packages/server/src/services/Cashflow/GetUncategorizedTransaction.ts @@ -0,0 +1,36 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { UncategorizedTransactionTransformer } from './UncategorizedTransactionTransformer'; + +@Service() +export class GetUncategorizedTransaction { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieves specific uncategorized cashflow transaction. + * @param {number} tenantId - Tenant id. + * @param {number} uncategorizedTransactionId - Uncategorized transaction id. + */ + public async getTransaction( + tenantId: number, + uncategorizedTransactionId: number + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + const transaction = await UncategorizedCashflowTransaction.query() + .findById(uncategorizedTransactionId) + .withGraphFetched('account') + .throwIfNotFound(); + + return this.transformer.transform( + tenantId, + transaction, + new UncategorizedTransactionTransformer() + ); + } +} diff --git a/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts new file mode 100644 index 000000000..36606f582 --- /dev/null +++ b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts @@ -0,0 +1,51 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { UncategorizedTransactionTransformer } from './UncategorizedTransactionTransformer'; +import { IGetUncategorizedTransactionsQuery } from '@/interfaces'; + +@Service() +export class GetUncategorizedTransactions { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieves the uncategorized cashflow transactions. + * @param {number} tenantId - Tenant id. + * @param {number} accountId - Account Id. + */ + public async getTransactions( + tenantId: number, + accountId: number, + query: IGetUncategorizedTransactionsQuery + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + // Parsed query with default values. + const _query = { + page: 1, + pageSize: 20, + ...query, + }; + const { results, pagination } = + await UncategorizedCashflowTransaction.query() + .where('accountId', accountId) + .where('categorized', false) + .withGraphFetched('account') + .orderBy('date', 'DESC') + .pagination(_query.page - 1, _query.pageSize); + + const data = await this.transformer.transform( + tenantId, + results, + new UncategorizedTransactionTransformer() + ); + return { + data, + pagination, + }; + } +} diff --git a/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts b/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts index 7c1c39d6f..ecc0d3267 100644 --- a/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts +++ b/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts @@ -1,11 +1,10 @@ import { Service, Inject } from 'typedi'; -import { isEmpty, pick } from 'lodash'; +import { pick } from 'lodash'; import { Knex } from 'knex'; import * as R from 'ramda'; import { ICashflowNewCommandDTO, ICashflowTransaction, - ICashflowTransactionLine, ICommandCashflowCreatedPayload, ICommandCashflowCreatingPayload, ICashflowTransactionInput, @@ -86,6 +85,8 @@ export default class NewCashflowTransactionService { 'cashflowAccountId', 'creditAccountId', 'branchId', + 'plaidTransactionId', + 'uncategorizedTransactionId', ]); // Retreive the next invoice number. const autoNextNumber = @@ -124,8 +125,8 @@ export default class NewCashflowTransactionService { public newCashflowTransaction = async ( tenantId: number, newTransactionDTO: ICashflowNewCommandDTO, - userId: number - ): Promise<{ cashflowTransaction: ICashflowTransaction }> => { + userId?: number + ): Promise => { const { CashflowTransaction, Account } = this.tenancy.models(tenantId); // Retrieves the cashflow account or throw not found error. @@ -174,7 +175,7 @@ export default class NewCashflowTransactionService { trx, } as ICommandCashflowCreatedPayload ); - return { cashflowTransaction }; + return cashflowTransaction; }); }; } diff --git a/packages/server/src/services/Cashflow/UncategorizeCashflowTransaction.ts b/packages/server/src/services/Cashflow/UncategorizeCashflowTransaction.ts new file mode 100644 index 000000000..ba6740685 --- /dev/null +++ b/packages/server/src/services/Cashflow/UncategorizeCashflowTransaction.ts @@ -0,0 +1,72 @@ +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import UnitOfWork from '../UnitOfWork'; +import events from '@/subscribers/events'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import { + ICashflowTransactionUncategorizedPayload, + ICashflowTransactionUncategorizingPayload, +} from '@/interfaces'; + +@Service() +export class UncategorizeCashflowTransaction { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private uow: UnitOfWork; + + /** + * Uncategorizes the given cashflow transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + */ + public async uncategorize( + tenantId: number, + uncategorizedTransactionId: number + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + const oldUncategorizedTransaction = + await UncategorizedCashflowTransaction.query() + .findById(uncategorizedTransactionId) + .throwIfNotFound(); + + // Updates the transaction under UOW. + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onTransactionUncategorizing` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionUncategorizing, + { + tenantId, + trx, + } as ICashflowTransactionUncategorizingPayload + ); + // Removes the ref relation with the related transaction. + const uncategorizedTransaction = + await UncategorizedCashflowTransaction.query(trx).updateAndFetchById( + uncategorizedTransactionId, + { + categorized: false, + categorizeRefId: null, + categorizeRefType: null, + } + ); + // Triggers `onTransactionUncategorized` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionUncategorized, + { + tenantId, + uncategorizedTransaction, + oldUncategorizedTransaction, + trx, + } as ICashflowTransactionUncategorizedPayload + ); + return uncategorizedTransaction; + }); + } +} diff --git a/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts b/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts new file mode 100644 index 000000000..85d1a1fbb --- /dev/null +++ b/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts @@ -0,0 +1,65 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; +import { formatNumber } from '@/utils'; + +export class UncategorizedTransactionTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {string[]} + */ + public includeAttributes = (): string[] => { + return [ + 'formattedAmount', + 'formattedDate', + 'formattetDepositAmount', + 'formattedWithdrawalAmount', + ]; + }; + + /** + * Formattes the transaction date. + * @param transaction + * @returns {string} + */ + public formattedDate(transaction) { + return this.formatDate(transaction.date); + } + + /** + * Formatted amount. + * @param transaction + * @returns {string} + */ + public formattedAmount(transaction) { + return formatNumber(transaction.amount, { + currencyCode: transaction.currencyCode, + }); + } + + /** + * Formatted deposit amount. + * @param transaction + * @returns {string} + */ + protected formattetDepositAmount(transaction) { + if (transaction.isDepositTransaction) { + return formatNumber(transaction.deposit, { + currencyCode: transaction.currencyCode, + }); + } + return ''; + } + + /** + * Formatted withdrawal amount. + * @param transaction + * @returns {string} + */ + protected formattedWithdrawalAmount(transaction) { + if (transaction.isWithdrawalTransaction) { + return formatNumber(transaction.withdrawal, { + currencyCode: transaction.currencyCode, + }); + } + return ''; + } +} diff --git a/packages/server/src/services/Cashflow/constants.ts b/packages/server/src/services/Cashflow/constants.ts index 2e664a519..bf448a549 100644 --- a/packages/server/src/services/Cashflow/constants.ts +++ b/packages/server/src/services/Cashflow/constants.ts @@ -8,7 +8,11 @@ export const ERRORS = { CREDIT_ACCOUNTS_IDS_NOT_FOUND: 'CREDIT_ACCOUNTS_IDS_NOT_FOUND', CREDIT_ACCOUNTS_HAS_INVALID_TYPE: 'CREDIT_ACCOUNTS_HAS_INVALID_TYPE', ACCOUNT_ID_HAS_INVALID_TYPE: 'ACCOUNT_ID_HAS_INVALID_TYPE', - ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS: 'account_has_associated_transactions' + ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS: 'account_has_associated_transactions', + TRANSACTION_ALREADY_CATEGORIZED: 'TRANSACTION_ALREADY_CATEGORIZED', + TRANSACTION_ALREADY_UNCATEGORIZED: 'TRANSACTION_ALREADY_UNCATEGORIZED', + UNCATEGORIZED_TRANSACTION_TYPE_INVALID: 'UNCATEGORIZED_TRANSACTION_TYPE_INVALID', + CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED: 'CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED' }; export enum CASHFLOW_DIRECTION { diff --git a/packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts b/packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts new file mode 100644 index 000000000..9460cc739 --- /dev/null +++ b/packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts @@ -0,0 +1,42 @@ +import { Inject, Service } from 'typedi'; +import events from '@/subscribers/events'; +import { ICashflowTransactionUncategorizedPayload } from '@/interfaces'; +import { DeleteCashflowTransaction } from '../DeleteCashflowTransactionService'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; + +@Service() +export class DeleteCashflowTransactionOnUncategorize { + @Inject() + private deleteCashflowTransactionService: DeleteCashflowTransaction; + + /** + * Attaches events with handlers. + */ + public attach = (bus) => { + bus.subscribe( + events.cashflow.onTransactionUncategorized, + this.deleteCashflowTransactionOnUncategorize.bind(this) + ); + }; + + /** + * Deletes the cashflow transaction on uncategorize transaction. + * @param {ICashflowTransactionUncategorizedPayload} payload + */ + public async deleteCashflowTransactionOnUncategorize({ + tenantId, + oldUncategorizedTransaction, + trx, + }: ICashflowTransactionUncategorizedPayload) { + // Deletes the cashflow transaction. + if ( + oldUncategorizedTransaction.categorizeRefType === 'CashflowTransaction' + ) { + await this.deleteCashflowTransactionService.deleteCashflowTransaction( + tenantId, + + oldUncategorizedTransaction.categorizeRefId + ); + } + } +} diff --git a/packages/server/src/services/Cashflow/subscribers/PreventDeleteTransactionsOnDelete.ts b/packages/server/src/services/Cashflow/subscribers/PreventDeleteTransactionsOnDelete.ts new file mode 100644 index 000000000..e45404461 --- /dev/null +++ b/packages/server/src/services/Cashflow/subscribers/PreventDeleteTransactionsOnDelete.ts @@ -0,0 +1,52 @@ +import { Inject, Service } from 'typedi'; +import events from '@/subscribers/events'; +import { ICommandCashflowDeletingPayload } from '@/interfaces'; +import { ServiceError } from '@/exceptions'; +import { ERRORS } from '../constants'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; + +@Service() +export class PreventDeleteTransactionOnDelete { + @Inject() + private tenancy: HasTenancyService; + + /** + * Attaches events with handlers. + */ + public attach = (bus) => { + bus.subscribe( + events.cashflow.onTransactionDeleting, + this.preventDeleteCashflowTransactionHasUncategorizedTransaction.bind( + this + ) + ); + }; + + /** + * Prevent delete cashflow transaction has converted from uncategorized transaction. + * @param {ICommandCashflowDeletingPayload} payload + */ + public async preventDeleteCashflowTransactionHasUncategorizedTransaction({ + tenantId, + oldCashflowTransaction, + trx, + }: ICommandCashflowDeletingPayload) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + if (oldCashflowTransaction.uncategorizedTransactionId) { + const foundTransactions = await UncategorizedCashflowTransaction.query( + trx + ).where({ + categorized: true, + categorizeRefId: oldCashflowTransaction.id, + categorizeRefType: 'CashflowTransaction', + }); + // Throw the error if the cashflow transaction still linked to uncategorized transaction. + if (foundTransactions.length > 0) { + throw new ServiceError( + ERRORS.CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED, + 'Cannot delete cashflow transaction converted from uncategorized transaction.' + ); + } + } + } +} diff --git a/packages/server/src/services/Cashflow/utils.ts b/packages/server/src/services/Cashflow/utils.ts index 31af4b1bc..ce95e8416 100644 --- a/packages/server/src/services/Cashflow/utils.ts +++ b/packages/server/src/services/Cashflow/utils.ts @@ -1,13 +1,19 @@ -import { upperFirst, camelCase } from 'lodash'; +import { upperFirst, camelCase, omit } from 'lodash'; import { CASHFLOW_TRANSACTION_TYPE, CASHFLOW_TRANSACTION_TYPE_META, ICashflowTransactionTypeMeta, } from './constants'; +import { + ICashflowNewCommandDTO, + ICashflowTransaction, + ICategorizeCashflowTransactioDTO, + IUncategorizedCashflowTransaction, +} from '@/interfaces'; /** * Ensures the given transaction type to transformed to appropriate format. - * @param {string} type + * @param {string} type * @returns {string} */ export const transformCashflowTransactionType = (type) => { @@ -32,3 +38,30 @@ export function getCashflowTransactionType( export const getCashflowAccountTransactionsTypes = () => { return Object.values(CASHFLOW_TRANSACTION_TYPE_META).map((meta) => meta.type); }; + +/** + * Tranasformes the given uncategorized transaction and categorized DTO + * to cashflow create DTO. + * @param {IUncategorizedCashflowTransaction} uncategorizeModel + * @param {ICategorizeCashflowTransactioDTO} categorizeDTO + * @returns {ICashflowNewCommandDTO} + */ +export const transformCategorizeTransToCashflow = ( + uncategorizeModel: IUncategorizedCashflowTransaction, + categorizeDTO: ICategorizeCashflowTransactioDTO +): ICashflowNewCommandDTO => { + return { + date: uncategorizeModel.date, + referenceNo: categorizeDTO.referenceNo || uncategorizeModel.referenceNo, + description: categorizeDTO.description || uncategorizeModel.description, + cashflowAccountId: uncategorizeModel.accountId, + creditAccountId: categorizeDTO.creditAccountId, + exchangeRate: categorizeDTO.exchangeRate || 1, + currencyCode: uncategorizeModel.currencyCode, + amount: uncategorizeModel.amount, + transactionNumber: categorizeDTO.transactionNumber, + transactionType: categorizeDTO.transactionType, + uncategorizedTransactionId: uncategorizeModel.id, + publish: true, + }; +}; diff --git a/packages/server/src/services/CreditNotes/CreditNoteTransformer.ts b/packages/server/src/services/CreditNotes/CreditNoteTransformer.ts index f532c2eab..6ed80a6f0 100644 --- a/packages/server/src/services/CreditNotes/CreditNoteTransformer.ts +++ b/packages/server/src/services/CreditNotes/CreditNoteTransformer.ts @@ -14,6 +14,7 @@ export class CreditNoteTransformer extends Transformer { 'formattedCreditNoteDate', 'formattedAmount', 'formattedCreditsUsed', + 'formattedSubtotal', 'entries', ]; }; @@ -60,6 +61,15 @@ export class CreditNoteTransformer extends Transformer { }); }; + /** + * Retrieves the formatted subtotal. + * @param {ICreditNote} credit + * @returns {string} + */ + protected formattedSubtotal = (credit): string => { + return formatNumber(credit.amount, { money: false }); + }; + /** * Retrieves the entries of the credit note. * @param {ICreditNote} credit diff --git a/packages/server/src/services/CreditNotes/EditCreditNote.ts b/packages/server/src/services/CreditNotes/EditCreditNote.ts index 074115c04..0e045227d 100644 --- a/packages/server/src/services/CreditNotes/EditCreditNote.ts +++ b/packages/server/src/services/CreditNotes/EditCreditNote.ts @@ -80,7 +80,7 @@ export default class EditCreditNote extends BaseCreditNotes { } as ICreditNoteEditingPayload); // Saves the credit note graph to the storage. - const creditNote = await CreditNote.query(trx).upsertGraph({ + const creditNote = await CreditNote.query(trx).upsertGraphAndFetch({ id: creditNoteId, ...creditNoteModel, }); diff --git a/packages/server/src/services/CreditNotes/GetCreditNotePdf.ts b/packages/server/src/services/CreditNotes/GetCreditNotePdf.ts index c34dfd330..c54c8096e 100644 --- a/packages/server/src/services/CreditNotes/GetCreditNotePdf.ts +++ b/packages/server/src/services/CreditNotes/GetCreditNotePdf.ts @@ -1,6 +1,7 @@ import { Inject, Service } from 'typedi'; import { ChromiumlyTenancy } from '../ChromiumlyTenancy/ChromiumlyTenancy'; import { TemplateInjectable } from '../TemplateInjectable/TemplateInjectable'; +import GetCreditNote from './GetCreditNote'; @Service() export default class GetCreditNotePdf { @@ -10,11 +11,19 @@ export default class GetCreditNotePdf { @Inject() private templateInjectable: TemplateInjectable; + @Inject() + private getCreditNoteService: GetCreditNote; + /** * Retrieve sale invoice pdf content. - * @param {} saleInvoice - + * @param {number} tenantId - Tenant id. + * @param {number} creditNoteId - Credit note id. */ - public async getCreditNotePdf(tenantId: number, creditNote) { + public async getCreditNotePdf(tenantId: number, creditNoteId: number) { + const creditNote = await this.getCreditNoteService.getCreditNote( + tenantId, + creditNoteId + ); const htmlContent = await this.templateInjectable.render( tenantId, 'modules/credit-note-standard', diff --git a/packages/server/src/services/ExchangeRates/ExchangeRateApplication.ts b/packages/server/src/services/ExchangeRates/ExchangeRateApplication.ts new file mode 100644 index 000000000..9e51c5d72 --- /dev/null +++ b/packages/server/src/services/ExchangeRates/ExchangeRateApplication.ts @@ -0,0 +1,21 @@ +import { Inject } from 'typedi'; +import { ExchangeRatesService } from './ExchangeRatesService'; +import { EchangeRateLatestPOJO, ExchangeRateLatestDTO } from '@/interfaces'; + +export class ExchangeRateApplication { + @Inject() + private exchangeRateService: ExchangeRatesService; + + /** + * Gets the latest exchange rate. + * @param {number} tenantId + * @param {ExchangeRateLatestDTO} exchangeRateLatestDTO + * @returns {Promise} + */ + public latest( + tenantId: number, + exchangeRateLatestDTO: ExchangeRateLatestDTO + ): Promise { + return this.exchangeRateService.latest(tenantId, exchangeRateLatestDTO); + } +} diff --git a/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts b/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts index 9bc63fbfd..4cde544ad 100644 --- a/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts +++ b/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts @@ -1,193 +1,37 @@ -import moment from 'moment'; -import { difference } from 'lodash'; -import { Service, Inject } from 'typedi'; -import { ServiceError } from '@/exceptions'; -import DynamicListingService from '@/services/DynamicListing/DynamicListService'; -import { - EventDispatcher, - EventDispatcherInterface, -} from 'decorators/eventDispatcher'; -import { - IExchangeRateDTO, - IExchangeRate, - IExchangeRatesService, - IExchangeRateEditDTO, - IExchangeRateFilter, -} from '@/interfaces'; -import TenancyService from '@/services/Tenancy/TenancyService'; - -const ERRORS = { - NOT_FOUND_EXCHANGE_RATES: 'NOT_FOUND_EXCHANGE_RATES', - EXCHANGE_RATE_PERIOD_EXISTS: 'EXCHANGE_RATE_PERIOD_EXISTS', - EXCHANGE_RATE_NOT_FOUND: 'EXCHANGE_RATE_NOT_FOUND', -}; +import { Service } from 'typedi'; +import { ExchangeRate } from '@/lib/ExchangeRate/ExchangeRate'; +import { ExchangeRateServiceType } from '@/lib/ExchangeRate/types'; +import { EchangeRateLatestPOJO, ExchangeRateLatestDTO } from '@/interfaces'; +import { TenantMetadata } from '@/system/models'; @Service() -export default class ExchangeRatesService implements IExchangeRatesService { - @Inject('logger') - logger: any; - - @EventDispatcher() - eventDispatcher: EventDispatcherInterface; - - @Inject() - tenancy: TenancyService; - - @Inject() - dynamicListService: DynamicListingService; - +export class ExchangeRatesService { /** - * Creates a new exchange rate. + * Gets the latest exchange rate. * @param {number} tenantId - * @param {IExchangeRateDTO} exchangeRateDTO - * @returns {Promise} + * @param {number} exchangeRateLatestDTO + * @returns {EchangeRateLatestPOJO} */ - public async newExchangeRate( + public async latest( tenantId: number, - exchangeRateDTO: IExchangeRateDTO - ): Promise { - const { ExchangeRate } = this.tenancy.models(tenantId); + exchangeRateLatestDTO: ExchangeRateLatestDTO + ): Promise { + const organization = await TenantMetadata.query().findOne({ tenantId }); - this.logger.info('[exchange_rates] trying to insert new exchange rate.', { - tenantId, - exchangeRateDTO, - }); - await this.validateExchangeRatePeriodExistance(tenantId, exchangeRateDTO); + // Assign the organization base currency as a default currency + // if no currency is provided + const fromCurrency = + exchangeRateLatestDTO.fromCurrency || organization.baseCurrency; + const toCurrency = + exchangeRateLatestDTO.toCurrency || organization.baseCurrency; - const exchangeRate = await ExchangeRate.query().insertAndFetch({ - ...exchangeRateDTO, - date: moment(exchangeRateDTO.date).format('YYYY-MM-DD'), - }); - this.logger.info('[exchange_rates] inserted successfully.', { - tenantId, - exchangeRateDTO, - }); - return exchangeRate; - } + const exchange = new ExchangeRate(ExchangeRateServiceType.OpenExchangeRate); + const exchangeRate = await exchange.latest(fromCurrency, toCurrency); - /** - * Edits the exchange rate details. - * @param {number} tenantId - Tenant id. - * @param {number} exchangeRateId - Exchange rate id. - * @param {IExchangeRateEditDTO} editExRateDTO - Edit exchange rate DTO. - */ - public async editExchangeRate( - tenantId: number, - exchangeRateId: number, - editExRateDTO: IExchangeRateEditDTO - ): Promise { - const { ExchangeRate } = this.tenancy.models(tenantId); - - this.logger.info('[exchange_rates] trying to edit exchange rate.', { - tenantId, - exchangeRateId, - editExRateDTO, - }); - await this.validateExchangeRateExistance(tenantId, exchangeRateId); - - await ExchangeRate.query() - .where('id', exchangeRateId) - .update({ ...editExRateDTO }); - this.logger.info('[exchange_rates] exchange rate edited successfully.', { - tenantId, - exchangeRateId, - editExRateDTO, - }); - } - - /** - * Deletes the given exchange rate. - * @param {number} tenantId - Tenant id. - * @param {number} exchangeRateId - Exchange rate id. - */ - public async deleteExchangeRate( - tenantId: number, - exchangeRateId: number - ): Promise { - const { ExchangeRate } = this.tenancy.models(tenantId); - await this.validateExchangeRateExistance(tenantId, exchangeRateId); - - await ExchangeRate.query().findById(exchangeRateId).delete(); - } - - /** - * Listing exchange rates details. - * @param {number} tenantId - Tenant id. - * @param {IExchangeRateFilter} exchangeRateFilter - Exchange rates list filter. - */ - public async listExchangeRates( - tenantId: number, - exchangeRateFilter: IExchangeRateFilter - ): Promise { - const { ExchangeRate } = this.tenancy.models(tenantId); - const dynamicFilter = await this.dynamicListService.dynamicList( - tenantId, - ExchangeRate, - exchangeRateFilter - ); - // Retrieve exchange rates by the given query. - const exchangeRates = await ExchangeRate.query() - .onBuild((query) => { - dynamicFilter.buildQuery()(query); - }) - .pagination(exchangeRateFilter.page - 1, exchangeRateFilter.pageSize); - - return exchangeRates; - } - - /** - * Validates period of the exchange rate existance. - * @param {number} tenantId - Tenant id. - * @param {IExchangeRateDTO} exchangeRateDTO - Exchange rate DTO. - * @return {Promise} - */ - private async validateExchangeRatePeriodExistance( - tenantId: number, - exchangeRateDTO: IExchangeRateDTO - ): Promise { - const { ExchangeRate } = this.tenancy.models(tenantId); - - this.logger.info('[exchange_rates] trying to validate period existance.', { - tenantId, - }); - const foundExchangeRate = await ExchangeRate.query() - .where('currency_code', exchangeRateDTO.currencyCode) - .where('date', exchangeRateDTO.date); - - if (foundExchangeRate.length > 0) { - this.logger.info('[exchange_rates] given exchange rate period exists.', { - tenantId, - }); - throw new ServiceError(ERRORS.EXCHANGE_RATE_PERIOD_EXISTS); - } - } - - /** - * Validate the given echange rate id existance. - * @param {number} tenantId - Tenant id. - * @param {number} exchangeRateId - Exchange rate id. - * @returns {Promise} - */ - private async validateExchangeRateExistance( - tenantId: number, - exchangeRateId: number - ) { - const { ExchangeRate } = this.tenancy.models(tenantId); - - this.logger.info( - '[exchange_rates] trying to validate exchange rate id existance.', - { tenantId, exchangeRateId } - ); - const foundExchangeRate = await ExchangeRate.query().findById( - exchangeRateId - ); - - if (!foundExchangeRate) { - this.logger.info('[exchange_rates] exchange rate not found.', { - tenantId, - exchangeRateId, - }); - throw new ServiceError(ERRORS.EXCHANGE_RATE_NOT_FOUND); - } + return { + baseCurrency: fromCurrency, + toCurrency: exchangeRateLatestDTO.toCurrency, + exchangeRate, + }; } } diff --git a/packages/server/src/services/Expenses/CRUD/EditExpense.ts b/packages/server/src/services/Expenses/CRUD/EditExpense.ts index 93b0acc62..e3aeb06ce 100644 --- a/packages/server/src/services/Expenses/CRUD/EditExpense.ts +++ b/packages/server/src/services/Expenses/CRUD/EditExpense.ts @@ -136,7 +136,7 @@ export class EditExpense { } as IExpenseEventEditingPayload); // Upsert the expense object with expense entries. - const expense: IExpense = await Expense.query(trx).upsertGraph({ + const expense: IExpense = await Expense.query(trx).upsertGraphAndFetch({ id: expenseId, ...expenseObj, }); diff --git a/packages/server/src/services/Expenses/CRUD/ExpenseCategoryTransformer.ts b/packages/server/src/services/Expenses/CRUD/ExpenseCategoryTransformer.ts new file mode 100644 index 000000000..3f8383c03 --- /dev/null +++ b/packages/server/src/services/Expenses/CRUD/ExpenseCategoryTransformer.ts @@ -0,0 +1,25 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; +import { ExpenseCategory } from '@/models'; +import { formatNumber } from '@/utils'; + +export class ExpenseCategoryTransformer extends Transformer { + /** + * Include these attributes to expense object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return ['amountFormatted']; + }; + + /** + * Retrieves the formatted amount. + * @param {ExpenseCategory} category + * @returns {string} + */ + protected amountFormatted(category: ExpenseCategory) { + return formatNumber(category.amount, { + currencyCode: this.context.currencyCode, + money: false, + }); + } +} diff --git a/packages/server/src/services/Expenses/CRUD/ExpenseTransformer.ts b/packages/server/src/services/Expenses/CRUD/ExpenseTransformer.ts index 2812a9261..89f461934 100644 --- a/packages/server/src/services/Expenses/CRUD/ExpenseTransformer.ts +++ b/packages/server/src/services/Expenses/CRUD/ExpenseTransformer.ts @@ -1,6 +1,7 @@ import { Transformer } from '@/lib/Transformer/Transformer'; import { formatNumber } from 'utils'; import { IExpense } from '@/interfaces'; +import { ExpenseCategoryTransformer } from './ExpenseCategoryTransformer'; export class ExpenseTransfromer extends Transformer { /** @@ -12,7 +13,8 @@ export class ExpenseTransfromer extends Transformer { 'formattedAmount', 'formattedLandedCostAmount', 'formattedAllocatedCostAmount', - 'formattedDate' + 'formattedDate', + 'categories', ]; }; @@ -56,5 +58,16 @@ export class ExpenseTransfromer extends Transformer { */ protected formattedDate = (expense: IExpense): string => { return this.formatDate(expense.paymentDate); - } + }; + + /** + * Retrieves the transformed expense categories. + * @param {IExpense} expense + * @returns {} + */ + protected categories = (expense: IExpense) => { + return this.item(expense.categories, new ExpenseCategoryTransformer(), { + currencyCode: expense.currencyCode, + }); + }; } diff --git a/packages/server/src/services/Expenses/ExpenseGLEntriesStorage.ts b/packages/server/src/services/Expenses/ExpenseGLEntriesStorage.ts index 0821c3bd4..76b450c39 100644 --- a/packages/server/src/services/Expenses/ExpenseGLEntriesStorage.ts +++ b/packages/server/src/services/Expenses/ExpenseGLEntriesStorage.ts @@ -1,7 +1,7 @@ import { Knex } from 'knex'; +import { Service, Inject } from 'typedi'; import LedgerStorageService from '@/services/Accounting/LedgerStorageService'; import HasTenancyService from '@/services/Tenancy/TenancyService'; -import { Service, Inject } from 'typedi'; import { ExpenseGLEntries } from './ExpenseGLEntries'; @Service() diff --git a/packages/server/src/services/Expenses/ExpenseGLEntriesSubscriber.ts b/packages/server/src/services/Expenses/ExpenseGLEntriesSubscriber.ts index 1a177876e..4c69b7f04 100644 --- a/packages/server/src/services/Expenses/ExpenseGLEntriesSubscriber.ts +++ b/packages/server/src/services/Expenses/ExpenseGLEntriesSubscriber.ts @@ -70,10 +70,10 @@ export class ExpensesWriteGLSubscriber { authorizedUser, trx, }: IExpenseEventEditPayload) => { - // In case expense published, write journal entries. - if (expense.publishedAt) return; + // Cannot continue if the expense is not published. + if (!expense.publishedAt) return; - await this.expenseGLEntries.writeExpenseGLEntries( + await this.expenseGLEntries.rewriteExpenseGLEntries( tenantId, expense.id, trx diff --git a/packages/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryApplication.ts b/packages/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryApplication.ts index 73c08b2d8..52e6db251 100644 --- a/packages/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryApplication.ts +++ b/packages/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryApplication.ts @@ -3,6 +3,7 @@ import { APAgingSummaryExportInjectable } from './APAgingSummaryExportInjectable import { APAgingSummaryTableInjectable } from './APAgingSummaryTableInjectable'; import { IAPAgingSummaryQuery } from '@/interfaces'; import { APAgingSummaryService } from './APAgingSummaryService'; +import { APAgingSummaryPdfInjectable } from './APAgingSummaryPdfInjectable'; @Service() export class APAgingSummaryApplication { @@ -15,6 +16,9 @@ export class APAgingSummaryApplication { @Inject() private APAgingSummarySheet: APAgingSummaryService; + @Inject() + private APAgingSumaryPdf: APAgingSummaryPdfInjectable; + /** * Retrieve the A/P aging summary in sheet format. * @param {number} tenantId @@ -50,4 +54,14 @@ export class APAgingSummaryApplication { public xlsx(tenantId: number, query: IAPAgingSummaryQuery) { return this.APAgingSummaryExport.xlsx(tenantId, query); } + + /** + * Retrieves the A/P aging summary in pdf format. + * @param {number} tenantId + * @param {IAPAgingSummaryQuery} query + * @returns {Promise} + */ + public pdf(tenantId: number, query: IAPAgingSummaryQuery) { + return this.APAgingSumaryPdf.pdf(tenantId, query); + } } diff --git a/packages/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryMeta.ts b/packages/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryMeta.ts new file mode 100644 index 000000000..a32fe6457 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryMeta.ts @@ -0,0 +1,26 @@ +import { Inject, Service } from 'typedi'; +import { IAgingSummaryMeta, IAgingSummaryQuery } from '@/interfaces'; +import { AgingSummaryMeta } from './AgingSummaryMeta'; + +@Service() +export class APAgingSummaryMeta { + @Inject() + private agingSummaryMeta: AgingSummaryMeta; + + /** + * Retrieve the aging summary meta. + * @param {number} tenantId - + * @returns {IBalanceSheetMeta} + */ + public async meta( + tenantId: number, + query: IAgingSummaryQuery + ): Promise { + const commonMeta = await this.agingSummaryMeta.meta(tenantId, query); + + return { + ...commonMeta, + sheetName: 'A/P Aging Summary', + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryPdfInjectable.ts b/packages/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryPdfInjectable.ts new file mode 100644 index 000000000..50ef588e8 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryPdfInjectable.ts @@ -0,0 +1,35 @@ +import { Inject, Service } from 'typedi'; +import { IAPAgingSummaryQuery } from '@/interfaces'; +import { TableSheetPdf } from '../TableSheetPdf'; +import { APAgingSummaryTableInjectable } from './APAgingSummaryTableInjectable'; +import { HtmlTableCss } from './_constants'; + +@Service() +export class APAgingSummaryPdfInjectable { + @Inject() + private APAgingSummaryTable: APAgingSummaryTableInjectable; + + @Inject() + private tableSheetPdf: TableSheetPdf; + + /** + * Converts the given A/P aging summary sheet table to pdf. + * @param {number} tenantId - Tenant ID. + * @param {IAPAgingSummaryQuery} query - Balance sheet query. + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: IAPAgingSummaryQuery + ): Promise { + const table = await this.APAgingSummaryTable.table(tenantId, query); + + return this.tableSheetPdf.convertToPdf( + tenantId, + table.table, + table.meta.sheetName, + table.meta.formattedAsDate, + HtmlTableCss + ); + } +} diff --git a/packages/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryService.ts b/packages/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryService.ts index fa3e6a2b3..49ee46448 100644 --- a/packages/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryService.ts +++ b/packages/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryService.ts @@ -1,18 +1,19 @@ import moment from 'moment'; import { Inject, Service } from 'typedi'; import { isEmpty } from 'lodash'; -import { IAPAgingSummaryQuery, IARAgingSummaryMeta } from '@/interfaces'; +import { IAPAgingSummaryQuery, IAPAgingSummarySheet } from '@/interfaces'; import TenancyService from '@/services/Tenancy/TenancyService'; import APAgingSummarySheet from './APAgingSummarySheet'; import { Tenant } from '@/system/models'; +import { APAgingSummaryMeta } from './APAgingSummaryMeta'; @Service() export class APAgingSummaryService { @Inject() - tenancy: TenancyService; + private tenancy: TenancyService; - @Inject('logger') - logger: any; + @Inject() + private APAgingSummaryMeta: APAgingSummaryMeta; /** * Default report query. @@ -35,35 +36,16 @@ export class APAgingSummaryService { }; } - /** - * Retrieve the balance sheet meta. - * @param {number} tenantId - - * @returns {IBalanceSheetMeta} - */ - reportMetadata(tenantId: number): IARAgingSummaryMeta { - const settings = this.tenancy.settings(tenantId); - - const organizationName = settings.get({ - group: 'organization', - key: 'name', - }); - const baseCurrency = settings.get({ - group: 'organization', - key: 'base_currency', - }); - - return { - organizationName, - baseCurrency, - }; - } - /** * Retrieve A/P aging summary report. * @param {number} tenantId - * @param {IAPAgingSummaryQuery} query - + * @returns {Promise} */ - async APAgingSummary(tenantId: number, query: IAPAgingSummaryQuery) { + public async APAgingSummary( + tenantId: number, + query: IAPAgingSummaryQuery + ): Promise { const { Bill } = this.tenancy.models(tenantId); const { vendorRepository } = this.tenancy.repositories(tenantId); @@ -111,11 +93,14 @@ export class APAgingSummaryService { const data = APAgingSummaryReport.reportData(); const columns = APAgingSummaryReport.reportColumns(); + // Retrieve the aging summary report meta. + const meta = await this.APAgingSummaryMeta.meta(tenantId, filter); + return { data, columns, query: filter, - meta: this.reportMetadata(tenantId), + meta, }; } } diff --git a/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryApplication.ts b/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryApplication.ts index d3282ca4b..f24932f13 100644 --- a/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryApplication.ts +++ b/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryApplication.ts @@ -3,6 +3,7 @@ import { IARAgingSummaryQuery } from '@/interfaces'; import { ARAgingSummaryTableInjectable } from './ARAgingSummaryTableInjectable'; import { ARAgingSummaryExportInjectable } from './ARAgingSummaryExportInjectable'; import ARAgingSummaryService from './ARAgingSummaryService'; +import { ARAgingSummaryPdfInjectable } from './ARAgingSummaryPdfInjectable'; @Service() export class ARAgingSummaryApplication { @@ -15,6 +16,9 @@ export class ARAgingSummaryApplication { @Inject() private ARAgingSummarySheet: ARAgingSummaryService; + @Inject() + private ARAgingSummaryPdf: ARAgingSummaryPdfInjectable; + /** * Retrieve the A/R aging summary sheet. * @param {number} tenantId @@ -50,4 +54,14 @@ export class ARAgingSummaryApplication { public csv(tenantId: number, query: IARAgingSummaryQuery) { return this.ARAgingSummaryExport.csv(tenantId, query); } + + /** + * Retrieves the A/R aging summary in pdf format. + * @param {number} tenantId + * @param {IARAgingSummaryQuery} query + * @returns {Promise} + */ + public pdf(tenantId: number, query: IARAgingSummaryQuery) { + return this.ARAgingSummaryPdf.pdf(tenantId, query); + } } diff --git a/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryMeta.ts b/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryMeta.ts new file mode 100644 index 000000000..dce2cb9d0 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryMeta.ts @@ -0,0 +1,26 @@ +import { Inject, Service } from 'typedi'; +import { IAgingSummaryMeta, IAgingSummaryQuery } from '@/interfaces'; +import { AgingSummaryMeta } from './AgingSummaryMeta'; + +@Service() +export class ARAgingSummaryMeta { + @Inject() + private agingSummaryMeta: AgingSummaryMeta; + + /** + * Retrieve the aging summary meta. + * @param {number} tenantId - + * @returns {IBalanceSheetMeta} + */ + public async meta( + tenantId: number, + query: IAgingSummaryQuery + ): Promise { + const commonMeta = await this.agingSummaryMeta.meta(tenantId, query); + + return { + ...commonMeta, + sheetName: 'A/R Aging Summary', + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryPdfInjectable.ts b/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryPdfInjectable.ts new file mode 100644 index 000000000..a3b75f6d3 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryPdfInjectable.ts @@ -0,0 +1,35 @@ +import { Inject, Service } from 'typedi'; +import { IARAgingSummaryQuery } from '@/interfaces'; +import { TableSheetPdf } from '../TableSheetPdf'; +import { ARAgingSummaryTableInjectable } from './ARAgingSummaryTableInjectable'; +import { HtmlTableCss } from './_constants'; + +@Service() +export class ARAgingSummaryPdfInjectable { + @Inject() + private ARAgingSummaryTable: ARAgingSummaryTableInjectable; + + @Inject() + private tableSheetPdf: TableSheetPdf; + + /** + * Converts the given balance sheet table to pdf. + * @param {number} tenantId - Tenant ID. + * @param {IBalanceSheetQuery} query - Balance sheet query. + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: IARAgingSummaryQuery + ): Promise { + const table = await this.ARAgingSummaryTable.table(tenantId, query); + + return this.tableSheetPdf.convertToPdf( + tenantId, + table.table, + table.meta.sheetName, + table.meta.formattedDateRange, + HtmlTableCss + ); + } +} diff --git a/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts b/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts index e13dfc276..d52709027 100644 --- a/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts +++ b/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts @@ -1,18 +1,19 @@ import moment from 'moment'; import { Inject, Service } from 'typedi'; import { isEmpty } from 'lodash'; -import { IARAgingSummaryQuery, IARAgingSummaryMeta } from '@/interfaces'; +import { IARAgingSummaryQuery } from '@/interfaces'; import TenancyService from '@/services/Tenancy/TenancyService'; import ARAgingSummarySheet from './ARAgingSummarySheet'; import { Tenant } from '@/system/models'; +import { ARAgingSummaryMeta } from './ARAgingSummaryMeta'; @Service() export default class ARAgingSummaryService { @Inject() - tenancy: TenancyService; + private tenancy: TenancyService; - @Inject('logger') - logger: any; + @Inject() + private ARAgingSummaryMeta: ARAgingSummaryMeta; /** * Default report query. @@ -35,29 +36,6 @@ export default class ARAgingSummaryService { }; } - /** - * Retrieve the balance sheet meta. - * @param {number} tenantId - - * @returns {IBalanceSheetMeta} - */ - reportMetadata(tenantId: number): IARAgingSummaryMeta { - const settings = this.tenancy.settings(tenantId); - - const organizationName = settings.get({ - group: 'organization', - key: 'name', - }); - const baseCurrency = settings.get({ - group: 'organization', - key: 'base_currency', - }); - - return { - organizationName, - baseCurrency, - }; - } - /** * Retrieve A/R aging summary report. * @param {number} tenantId - Tenant id. @@ -110,11 +88,14 @@ export default class ARAgingSummaryService { const data = ARAgingSummaryReport.reportData(); const columns = ARAgingSummaryReport.reportColumns(); + // Retrieve the aging summary report meta. + const meta = await this.ARAgingSummaryMeta.meta(tenantId, filter); + return { data, columns, query: filter, - meta: this.reportMetadata(tenantId), + meta, }; } } diff --git a/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryTableInjectable.ts b/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryTableInjectable.ts index 10ac9ee8c..6f702cfe4 100644 --- a/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryTableInjectable.ts +++ b/packages/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryTableInjectable.ts @@ -19,6 +19,7 @@ export class ARAgingSummaryTableInjectable { query: IARAgingSummaryQuery ): Promise { const report = await this.ARAgingSummarySheet.ARAgingSummary( + tenantId, query ); diff --git a/packages/server/src/services/FinancialStatements/AgingSummary/AgingSummaryMeta.ts b/packages/server/src/services/FinancialStatements/AgingSummary/AgingSummaryMeta.ts new file mode 100644 index 000000000..5d3599997 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/AgingSummary/AgingSummaryMeta.ts @@ -0,0 +1,30 @@ +import { Inject } from 'typedi'; +import { FinancialSheetMeta } from '../FinancialSheetMeta'; +import { IAgingSummaryMeta, IAgingSummaryQuery } from '@/interfaces'; +import moment from 'moment'; + +export class AgingSummaryMeta { + @Inject() + private financialSheetMeta: FinancialSheetMeta; + + /** + * Retrieve the aging summary meta. + * @param {number} tenantId - + * @returns {IBalanceSheetMeta} + */ + public async meta( + tenantId: number, + query: IAgingSummaryQuery + ): Promise { + const commonMeta = await this.financialSheetMeta.meta(tenantId); + const formattedAsDate = moment(query.asDate).format('YYYY/MM/DD'); + const formattedDateRange = `As ${formattedAsDate}`; + + return { + ...commonMeta, + sheetName: 'A/P Aging Summary', + formattedAsDate, + formattedDateRange, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/AgingSummary/AgingSummaryTable.ts b/packages/server/src/services/FinancialStatements/AgingSummary/AgingSummaryTable.ts index 3b74c76ac..0b6c61cbd 100644 --- a/packages/server/src/services/FinancialStatements/AgingSummary/AgingSummaryTable.ts +++ b/packages/server/src/services/FinancialStatements/AgingSummary/AgingSummaryTable.ts @@ -56,7 +56,7 @@ export default abstract class AgingSummaryTable extends R.compose( node: IAgingSummaryContact | IAgingSummaryTotal ): ITableColumnAccessor[] => { return node.aging.map((aging, index) => ({ - key: 'aging', + key: 'aging_period', accessor: `aging[${index}].total.formattedAmount`, })); }; diff --git a/packages/server/src/services/FinancialStatements/AgingSummary/_constants.ts b/packages/server/src/services/FinancialStatements/AgingSummary/_constants.ts index 961f0b7ed..fd818e160 100644 --- a/packages/server/src/services/FinancialStatements/AgingSummary/_constants.ts +++ b/packages/server/src/services/FinancialStatements/AgingSummary/_constants.ts @@ -2,3 +2,20 @@ export enum AgingSummaryRowType { Contact = 'contact', Total = 'total', } + +export const HtmlTableCss = ` +table tr.row-type--total td{ + font-weight: 600; + border-top: 1px solid #bbb; + border-bottom: 3px double #333; +} + +table .column--current, +table .column--aging_period, +table .column--total, +table .cell--current, +table .cell--aging_period, +table .cell--total { + text-align: right; +} +`; diff --git a/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetApplication.ts b/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetApplication.ts index 01ab77bfe..0c965a5f5 100644 --- a/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetApplication.ts +++ b/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetApplication.ts @@ -54,4 +54,14 @@ export class BalanceSheetApplication { public csv(tenantId: number, query: IBalanceSheetQuery): Promise { return this.balanceSheetExport.csv(tenantId, query); } + + /** + * Retrieves the balance sheet in pdf format. + * @param {number} tenantId + * @param {IBalanceSheetQuery} query + * @returns {Promise} + */ + public pdf(tenantId: number, query: IBalanceSheetQuery) { + return this.balanceSheetExport.pdf(tenantId, query); + } } diff --git a/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetExportInjectable.ts b/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetExportInjectable.ts index 2c43d5f80..9198d8536 100644 --- a/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetExportInjectable.ts +++ b/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetExportInjectable.ts @@ -2,12 +2,16 @@ import { Inject, Service } from 'typedi'; import { BalanceSheetTableInjectable } from './BalanceSheetTableInjectable'; import { TableSheet } from '@/lib/Xlsx/TableSheet'; import { IBalanceSheetQuery } from '@/interfaces'; +import { BalanceSheetPdfInjectable } from './BalanceSheetPdfInjectable'; @Service() export class BalanceSheetExportInjectable { @Inject() private balanceSheetTable: BalanceSheetTableInjectable; + @Inject() + private balanceSheetPdf: BalanceSheetPdfInjectable; + /** * Retrieves the trial balance sheet in XLSX format. * @param {number} tenantId @@ -40,4 +44,17 @@ export class BalanceSheetExportInjectable { return tableCsv; } + + /** + * Retrieves the balance sheet in pdf format. + * @param {number} tenantId + * @param {IBalanceSheetQuery} query + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: IBalanceSheetQuery + ): Promise { + return this.balanceSheetPdf.pdf(tenantId, query); + } } diff --git a/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetInjectable.ts b/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetInjectable.ts index 02e136ca1..5e171110b 100644 --- a/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetInjectable.ts +++ b/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetInjectable.ts @@ -4,15 +4,12 @@ import { IBalanceSheetStatementService, IBalanceSheetQuery, IBalanceSheetStatement, - IBalanceSheetMeta, } from '@/interfaces'; import TenancyService from '@/services/Tenancy/TenancyService'; -import Journal from '@/services/Accounting/JournalPoster'; import BalanceSheetStatement from './BalanceSheet'; -import InventoryService from '@/services/Inventory/Inventory'; -import { parseBoolean } from 'utils'; import { Tenant } from '@/system/models'; import BalanceSheetRepository from './BalanceSheetRepository'; +import { BalanceSheetMetaInjectable } from './BalanceSheetMeta'; @Service() export default class BalanceSheetStatementService @@ -22,7 +19,7 @@ export default class BalanceSheetStatementService private tenancy: TenancyService; @Inject() - private inventoryService: InventoryService; + private balanceSheetMeta: BalanceSheetMetaInjectable; /** * Defaults balance sheet filter query. @@ -62,33 +59,6 @@ export default class BalanceSheetStatementService }; } - /** - * Retrieve the balance sheet meta. - * @param {number} tenantId - - * @returns {IBalanceSheetMeta} - */ - private reportMetadata(tenantId: number): IBalanceSheetMeta { - const settings = this.tenancy.settings(tenantId); - - const isCostComputeRunning = - this.inventoryService.isItemsCostComputeRunning(tenantId); - - const organizationName = settings.get({ - group: 'organization', - key: 'name', - }); - const baseCurrency = settings.get({ - group: 'organization', - key: 'base_currency', - }); - - return { - isCostComputeRunning: parseBoolean(isCostComputeRunning, false), - organizationName, - baseCurrency, - }; - } - /** * Retrieve balance sheet statement. * @param {number} tenantId @@ -112,6 +82,7 @@ export default class BalanceSheetStatementService const models = this.tenancy.models(tenantId); const balanceSheetRepo = new BalanceSheetRepository(models, filter); + // Loads all resources. await balanceSheetRepo.asyncInitialize(); // Balance sheet report instance. @@ -122,12 +93,15 @@ export default class BalanceSheetStatementService i18n ); // Balance sheet data. - const balanceSheetData = balanceSheetInstanace.reportData(); + const data = balanceSheetInstanace.reportData(); + + // Balance sheet meta. + const meta = await this.balanceSheetMeta.meta(tenantId, filter); return { - data: balanceSheetData, query: filter, - meta: this.reportMetadata(tenantId), + data, + meta, }; } } diff --git a/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetMeta.ts b/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetMeta.ts new file mode 100644 index 000000000..c7a3e87c0 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetMeta.ts @@ -0,0 +1,32 @@ +import { Inject, Service } from 'typedi'; +import { FinancialSheetMeta } from '../FinancialSheetMeta'; +import { IBalanceSheetMeta, IBalanceSheetQuery } from '@/interfaces'; +import moment from 'moment'; + +@Service() +export class BalanceSheetMetaInjectable { + @Inject() + private financialSheetMeta: FinancialSheetMeta; + + /** + * Retrieve the balance sheet meta. + * @param {number} tenantId - + * @returns {IBalanceSheetMeta} + */ + public async meta( + tenantId: number, + query: IBalanceSheetQuery + ): Promise { + const commonMeta = await this.financialSheetMeta.meta(tenantId); + const formattedAsDate = moment(query.toDate).format('YYYY/MM/DD'); + const formattedDateRange = `As ${formattedAsDate}`; + const sheetName = 'Balance Sheet Statement'; + + return { + ...commonMeta, + sheetName, + formattedAsDate, + formattedDateRange, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetPdfInjectable.ts b/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetPdfInjectable.ts new file mode 100644 index 000000000..4cbb43e8a --- /dev/null +++ b/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetPdfInjectable.ts @@ -0,0 +1,35 @@ +import { Inject, Service } from 'typedi'; +import { IBalanceSheetQuery } from '@/interfaces'; +import { BalanceSheetTableInjectable } from './BalanceSheetTableInjectable'; +import { TableSheetPdf } from '../TableSheetPdf'; +import { HtmlTableCustomCss } from './constants'; + +@Service() +export class BalanceSheetPdfInjectable { + @Inject() + private balanceSheetTable: BalanceSheetTableInjectable; + + @Inject() + private tableSheetPdf: TableSheetPdf; + + /** + * Converts the given balance sheet table to pdf. + * @param {number} tenantId - Tenant ID. + * @param {IBalanceSheetQuery} query - Balance sheet query. + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: IBalanceSheetQuery + ): Promise { + const table = await this.balanceSheetTable.table(tenantId, query); + + return this.tableSheetPdf.convertToPdf( + tenantId, + table.table, + table.meta.sheetName, + table.meta.formattedDateRange, + HtmlTableCustomCss + ); + } +} diff --git a/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetTable.ts b/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetTable.ts index 349b1a8f4..8e6a1f92b 100644 --- a/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetTable.ts +++ b/packages/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetTable.ts @@ -41,15 +41,16 @@ export default class BalanceSheetTable extends R.compose( BalanceSheetBase )(FinancialSheet) { /** - * @param {} + * Balance sheet data. + * @param {IBalanceSheetStatementData} */ - reportData: IBalanceSheetStatementData; + private reportData: IBalanceSheetStatementData; /** * Balance sheet query. - * @parma {} + * @parma {BalanceSheetQuery} */ - query: BalanceSheetQuery; + private query: BalanceSheetQuery; /** * Constructor method. @@ -215,13 +216,13 @@ export default class BalanceSheetTable extends R.compose( /** * Retrieves the total children columns. - * @returns {ITableColumn[]} + * @returns {ITableColumn[]} */ private totalColumnChildren = (): ITableColumn[] => { return R.compose( R.unless( R.isEmpty, - R.concat([{ key: 'total', Label: this.i18n.__('balance_sheet.total') }]) + R.concat([{ key: 'total', label: this.i18n.__('balance_sheet.total') }]) ), R.concat(this.percentageColumns()), R.concat(this.getPreviousYearColumns()), diff --git a/packages/server/src/services/FinancialStatements/BalanceSheet/constants.ts b/packages/server/src/services/FinancialStatements/BalanceSheet/constants.ts index 8862408a1..2081d2859 100644 --- a/packages/server/src/services/FinancialStatements/BalanceSheet/constants.ts +++ b/packages/server/src/services/FinancialStatements/BalanceSheet/constants.ts @@ -12,3 +12,53 @@ export enum IROW_TYPE { NET_INCOME = 'NET_INCOME', TOTAL = 'TOTAL', } + +export const HtmlTableCustomCss = ` +table tr.row-type--total td { + font-weight: 600; + border-top: 1px solid #bbb; + color: #000; +} +table tr.row-type--total.row-id--assets td, +table tr.row-type--total.row-id--liability-equity td { + border-bottom: 3px double #000; +} +table .column--name, +table .cell--name { + width: 400px; +} + +table .column--total { + width: 25%; +} + +table td.cell--total, +table td.cell--previous_year, +table td.cell--previous_year_change, +table td.cell--previous_year_percentage, + +table td.cell--previous_period, +table td.cell--previous_period_change, +table td.cell--previous_period_percentage, + +table td.cell--percentage_of_row, +table td.cell--percentage_of_column, +table td[class*="cell--date-range"] { + text-align: right; +} + +table .column--total, +table .column--previous_year, +table .column--previous_year_change, +table .column--previous_year_percentage, + +table .column--previous_period, +table .column--previous_period_change, +table .column--previous_period_percentage, + +table .column--percentage_of_row, +table .column--percentage_of_column, +table [class*="column--date-range"] { + text-align: right; +} +`; diff --git a/packages/server/src/services/FinancialStatements/CashFlow/CashFlowService.ts b/packages/server/src/services/FinancialStatements/CashFlow/CashFlowService.ts index 09fdf91ca..201a31be4 100644 --- a/packages/server/src/services/FinancialStatements/CashFlow/CashFlowService.ts +++ b/packages/server/src/services/FinancialStatements/CashFlow/CashFlowService.ts @@ -8,7 +8,7 @@ import { ICashFlowStatementQuery, ICashFlowStatementDOO, IAccountTransaction, - ICashFlowStatementMeta + ICashFlowStatementMeta, } from '@/interfaces'; import CashFlowStatement from './CashFlow'; import Ledger from '@/services/Accounting/Ledger'; @@ -16,6 +16,7 @@ import CashFlowRepository from './CashFlowRepository'; import InventoryService from '@/services/Inventory/Inventory'; import { parseBoolean } from 'utils'; import { Tenant } from '@/system/models'; +import { CashflowSheetMeta } from './CashflowSheetMeta'; @Service() export default class CashFlowStatementService @@ -31,6 +32,9 @@ export default class CashFlowStatementService @Inject() inventoryService: InventoryService; + @Inject() + private cashflowSheetMeta: CashflowSheetMeta; + /** * Defaults balance sheet filter query. * @return {IBalanceSheetQuery} @@ -138,38 +142,13 @@ export default class CashFlowStatementService tenant.metadata.baseCurrency, i18n ); + // Retrieve the cashflow sheet meta. + const meta = await this.cashflowSheetMeta.meta(tenantId, filter); return { data: cashFlowInstance.reportData(), query: filter, - meta: this.reportMetadata(tenantId), + meta, }; } - - /** - * Retrieve the balance sheet meta. - * @param {number} tenantId - - * @returns {ICashFlowStatementMeta} - */ - private reportMetadata(tenantId: number): ICashFlowStatementMeta { - const settings = this.tenancy.settings(tenantId); - - const isCostComputeRunning = this.inventoryService - .isItemsCostComputeRunning(tenantId); - - const organizationName = settings.get({ - group: 'organization', - key: 'name', - }); - const baseCurrency = settings.get({ - group: 'organization', - key: 'base_currency', - }); - - return { - isCostComputeRunning: parseBoolean(isCostComputeRunning, false), - organizationName, - baseCurrency - }; - } } diff --git a/packages/server/src/services/FinancialStatements/CashFlow/CashFlowTable.ts b/packages/server/src/services/FinancialStatements/CashFlow/CashFlowTable.ts index b3470e1a4..7b49a685c 100644 --- a/packages/server/src/services/FinancialStatements/CashFlow/CashFlowTable.ts +++ b/packages/server/src/services/FinancialStatements/CashFlow/CashFlowTable.ts @@ -76,7 +76,7 @@ export default class CashFlowTable implements ICashFlowTable { */ private commonColumns = () => { return R.compose( - R.concat([{ key: 'label', accessor: 'label' }]), + R.concat([{ key: 'name', accessor: 'label' }]), R.when( R.always(this.isDisplayColumnsBy(DISPLAY_COLUMNS_BY.DATE_PERIODS)), R.concat(this.datePeriodsColumnsAccessors()) diff --git a/packages/server/src/services/FinancialStatements/CashFlow/CashflowSheetApplication.ts b/packages/server/src/services/FinancialStatements/CashFlow/CashflowSheetApplication.ts index 0fd8b7357..72a587f52 100644 --- a/packages/server/src/services/FinancialStatements/CashFlow/CashflowSheetApplication.ts +++ b/packages/server/src/services/FinancialStatements/CashFlow/CashflowSheetApplication.ts @@ -3,6 +3,7 @@ import { CashflowExportInjectable } from './CashflowExportInjectable'; import { ICashFlowStatementQuery } from '@/interfaces'; import CashFlowStatementService from './CashFlowService'; import { CashflowTableInjectable } from './CashflowTableInjectable'; +import { CashflowTablePdfInjectable } from './CashflowTablePdfInjectable'; @Service() export class CashflowSheetApplication { @@ -15,6 +16,9 @@ export class CashflowSheetApplication { @Inject() private cashflowTable: CashflowTableInjectable; + @Inject() + private cashflowPdf: CashflowTablePdfInjectable; + /** * Retrieves the cashflow sheet * @param {number} tenantId @@ -55,4 +59,17 @@ export class CashflowSheetApplication { ): Promise { return this.cashflowExport.csv(tenantId, query); } + + /** + * Retrieves the cashflow sheet in pdf format. + * @param {number} tenantId + * @param {ICashFlowStatementQuery} query + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: ICashFlowStatementQuery + ): Promise { + return this.cashflowPdf.pdf(tenantId, query); + } } diff --git a/packages/server/src/services/FinancialStatements/CashFlow/CashflowSheetMeta.ts b/packages/server/src/services/FinancialStatements/CashFlow/CashflowSheetMeta.ts new file mode 100644 index 000000000..3a1dd40dc --- /dev/null +++ b/packages/server/src/services/FinancialStatements/CashFlow/CashflowSheetMeta.ts @@ -0,0 +1,36 @@ +import { Inject, Service } from 'typedi'; +import moment from 'moment'; +import { ICashFlowStatementMeta, ICashFlowStatementQuery } from '@/interfaces'; +import { FinancialSheetMeta } from '../FinancialSheetMeta'; + +@Service() +export class CashflowSheetMeta { + @Inject() + private financialSheetMeta: FinancialSheetMeta; + + /** + * CAshflow sheet meta. + * @param {number} tenantId + * @param {ICashFlowStatementQuery} query + * @returns {Promise} + */ + public async meta( + tenantId: number, + query: ICashFlowStatementQuery + ): Promise { + const meta = await this.financialSheetMeta.meta(tenantId); + 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 = 'Statement of Cash Flow'; + + return { + ...meta, + sheetName, + formattedToDate, + formattedFromDate, + formattedDateRange, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/CashFlow/CashflowTablePdfInjectable.ts b/packages/server/src/services/FinancialStatements/CashFlow/CashflowTablePdfInjectable.ts new file mode 100644 index 000000000..be7cb5382 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/CashFlow/CashflowTablePdfInjectable.ts @@ -0,0 +1,34 @@ +import { Inject } from 'typedi'; +import { CashflowTableInjectable } from './CashflowTableInjectable'; +import { TableSheetPdf } from '../TableSheetPdf'; +import { ICashFlowStatementQuery } from '@/interfaces'; +import { HtmlTableCustomCss } from './constants'; + +export class CashflowTablePdfInjectable { + @Inject() + private cashflowTable: CashflowTableInjectable; + + @Inject() + private tableSheetPdf: TableSheetPdf; + + /** + * Converts the given cashflow sheet table to pdf. + * @param {number} tenantId - Tenant ID. + * @param {IBalanceSheetQuery} query - Balance sheet query. + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: ICashFlowStatementQuery + ): Promise { + const table = await this.cashflowTable.table(tenantId, query); + + return this.tableSheetPdf.convertToPdf( + tenantId, + table.table, + table.meta.sheetName, + table.meta.formattedDateRange, + HtmlTableCustomCss + ); + } +} diff --git a/packages/server/src/services/FinancialStatements/CashFlow/constants.ts b/packages/server/src/services/FinancialStatements/CashFlow/constants.ts index 58a92ec19..f3f1858fb 100644 --- a/packages/server/src/services/FinancialStatements/CashFlow/constants.ts +++ b/packages/server/src/services/FinancialStatements/CashFlow/constants.ts @@ -1,8 +1,33 @@ - - export const DISPLAY_COLUMNS_BY = { DATE_PERIODS: 'date_periods', TOTAL: 'total', }; -export const MAP_CONFIG = { childrenPath: 'children', pathFormat: 'array' }; \ No newline at end of file +export const MAP_CONFIG = { childrenPath: 'children', pathFormat: 'array' }; +export const HtmlTableCustomCss = ` +table tr.row-type--accounts td { + border-top: 1px solid #bbb; +} +table tr.row-id--cash-end-period td { + border-bottom: 3px double #333; +} +table tr.row-type--total { + font-weight: 600; +} +table tr.row-type--total td { + color: #000; +} +table tr.row-type--total:not(:first-child) td { + border-top: 1px solid #bbb; +} +table .column--name, +table .cell--name { + width: 400px; +} +table .column--total, +table .cell--total, +table [class*="column--date-range"], +table [class*="cell--date-range"] { + text-align: right; +} +`; diff --git a/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryApplication.ts b/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryApplication.ts index 964cd91a9..78c532d08 100644 --- a/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryApplication.ts +++ b/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryApplication.ts @@ -3,6 +3,7 @@ import { CustomerBalanceSummaryExportInjectable } from './CustomerBalanceSummary import { CustomerBalanceSummaryTableInjectable } from './CustomerBalanceSummaryTableInjectable'; import { ICustomerBalanceSummaryQuery } from '@/interfaces'; import { CustomerBalanceSummaryService } from './CustomerBalanceSummaryService'; +import { CustomerBalanceSummaryPdf } from './CustomerBalanceSummaryPdf'; @Service() export class CustomerBalanceSummaryApplication { @@ -14,6 +15,9 @@ export class CustomerBalanceSummaryApplication { @Inject() private customerBalanceSummarySheet: CustomerBalanceSummaryService; + + @Inject() + private customerBalanceSummaryPdf: CustomerBalanceSummaryPdf; /** * Retrieves the customer balance sheet in json format. @@ -57,4 +61,14 @@ export class CustomerBalanceSummaryApplication { public csv(tenantId: number, query: ICustomerBalanceSummaryQuery) { return this.customerBalanceSummaryExport.csv(tenantId, query); } + + /** + * Retrieves the customer balance sheet in PDF format. + * @param {number} tenantId + * @param {ICustomerBalanceSummaryQuery} query + * @returns {Promise} + */ + public pdf(tenantId: number, query: ICustomerBalanceSummaryQuery) { + return this.customerBalanceSummaryPdf.pdf(tenantId, query); + } } diff --git a/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryMeta.ts b/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryMeta.ts new file mode 100644 index 000000000..7639e8c4f --- /dev/null +++ b/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryMeta.ts @@ -0,0 +1,35 @@ +import moment from 'moment'; +import { Inject, Service } from 'typedi'; +import { + ICustomerBalanceSummaryMeta, + ICustomerBalanceSummaryQuery, +} from '@/interfaces'; +import { FinancialSheetMeta } from '../FinancialSheetMeta'; + +@Service() +export class CustomerBalanceSummaryMeta { + @Inject() + private financialSheetMeta: FinancialSheetMeta; + + /** + * Retrieves the customer balance summary meta. + * @param {number} tenantId + * @param {ICustomerBalanceSummaryQuery} query + * @returns {Promise} + */ + async meta( + tenantId: number, + query: ICustomerBalanceSummaryQuery + ): Promise { + const commonMeta = await this.financialSheetMeta.meta(tenantId); + const formattedAsDate = moment(query.asDate).format('YYYY/MM/DD'); + const formattedDateRange = `As ${formattedAsDate}`; + + return { + ...commonMeta, + sheetName: 'Customer Balance Summary', + formattedAsDate, + formattedDateRange, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryPdf.ts b/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryPdf.ts new file mode 100644 index 000000000..38d36fd48 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryPdf.ts @@ -0,0 +1,36 @@ +import { Inject, Service } from 'typedi'; +import { ICustomerBalanceSummaryQuery } from '@/interfaces'; + +import { TableSheetPdf } from '../TableSheetPdf'; +import { CustomerBalanceSummaryTableInjectable } from './CustomerBalanceSummaryTableInjectable'; +import { HtmlTableCustomCss } from './constants'; + +@Service() +export class CustomerBalanceSummaryPdf { + @Inject() + private customerBalanceSummaryTable: CustomerBalanceSummaryTableInjectable; + + @Inject() + private tableSheetPdf: TableSheetPdf; + + /** + * Converts the given customer balance summary sheet table to pdf. + * @param {number} tenantId - Tenant ID. + * @param {IAPAgingSummaryQuery} query - Balance sheet query. + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: ICustomerBalanceSummaryQuery + ): Promise { + const table = await this.customerBalanceSummaryTable.table(tenantId, query); + + return this.tableSheetPdf.convertToPdf( + tenantId, + table.table, + table.meta.sheetName, + table.meta.formattedDateRange, + HtmlTableCustomCss + ); + } +} diff --git a/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryService.ts b/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryService.ts index 78afc3bb2..38edc5174 100644 --- a/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryService.ts +++ b/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryService.ts @@ -1,4 +1,4 @@ -import { Inject } from 'typedi'; +import { Inject, Service } from 'typedi'; import moment from 'moment'; import * as R from 'ramda'; import { @@ -12,13 +12,18 @@ import { CustomerBalanceSummaryReport } from './CustomerBalanceSummary'; import Ledger from '@/services/Accounting/Ledger'; import CustomerBalanceSummaryRepository from './CustomerBalanceSummaryRepository'; import { Tenant } from '@/system/models'; +import { CustomerBalanceSummaryMeta } from './CustomerBalanceSummaryMeta'; +@Service() export class CustomerBalanceSummaryService implements ICustomerBalanceSummaryService { @Inject() private reportRepository: CustomerBalanceSummaryRepository; + @Inject() + private customerBalanceSummaryMeta: CustomerBalanceSummaryMeta; + /** * Defaults balance sheet filter query. * @return {ICustomerBalanceSummaryQuery} @@ -96,10 +101,13 @@ export class CustomerBalanceSummaryService filter, tenant.metadata.baseCurrency ); + // Retrieve the customer balance summary meta. + const meta = await this.customerBalanceSummaryMeta.meta(tenantId, filter); return { data: report.reportData(), query: filter, + meta, }; } } diff --git a/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryTableInjectable.ts b/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryTableInjectable.ts index 56450d4ca..ec5a9c021 100644 --- a/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryTableInjectable.ts +++ b/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryTableInjectable.ts @@ -26,19 +26,20 @@ export class CustomerBalanceSummaryTableInjectable { filter: ICustomerBalanceSummaryQuery ): Promise { const i18n = this.tenancy.i18n(tenantId); - const { data, query } = + const { data, query, meta } = await this.customerBalanceSummaryService.customerBalanceSummary( tenantId, filter ); - const tableRows = new CustomerBalanceSummaryTable(data, filter, i18n); + const table = new CustomerBalanceSummaryTable(data, filter, i18n); return { table: { - columns: tableRows.tableColumns(), - rows: tableRows.tableRows(), + columns: table.tableColumns(), + rows: table.tableRows(), }, query, + meta, }; } } diff --git a/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryTableRows.ts b/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryTableRows.ts index e6cdc6415..e4ed687a4 100644 --- a/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryTableRows.ts +++ b/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryTableRows.ts @@ -52,7 +52,7 @@ export class CustomerBalanceSummaryTable { */ private getCustomerColumnsAccessor = (): IColumnMapperMeta[] => { const columns = [ - { key: 'customerName', accessor: 'customerName' }, + { key: 'name', accessor: 'customerName' }, { key: 'total', accessor: 'total.formattedAmount' }, ]; return R.compose( @@ -85,7 +85,7 @@ export class CustomerBalanceSummaryTable { */ private getTotalColumnsAccessor = (): IColumnMapperMeta[] => { const columns = [ - { key: 'total', value: this.i18n.__('Total') }, + { key: 'name', value: this.i18n.__('Total') }, { key: 'total', accessor: 'total.formattedAmount' }, ]; return R.compose( diff --git a/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/constants.ts b/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/constants.ts new file mode 100644 index 000000000..513a3dcdb --- /dev/null +++ b/packages/server/src/services/FinancialStatements/CustomerBalanceSummary/constants.ts @@ -0,0 +1,14 @@ +export const HtmlTableCustomCss = ` +table tr.row-type--total td { + font-weight: 600; + border-top: 1px solid #bbb; + border-bottom: 3px double #333; +} +table .column--name { + width: 65%; +} +table .column--total, +table .cell--total { + text-align: right; +} +`; diff --git a/packages/server/src/services/FinancialStatements/FinancialSheetMeta.ts b/packages/server/src/services/FinancialStatements/FinancialSheetMeta.ts new file mode 100644 index 000000000..130f3caaa --- /dev/null +++ b/packages/server/src/services/FinancialStatements/FinancialSheetMeta.ts @@ -0,0 +1,34 @@ +import { TenantMetadata } from '@/system/models'; +import { Inject, Service } from 'typedi'; +import InventoryService from '../Inventory/Inventory'; +import { IFinancialSheetCommonMeta } from '@/interfaces'; + +@Service() +export class FinancialSheetMeta { + @Inject() + private inventoryService: InventoryService; + + /** + * Retrieves the common meta data of the financial sheet. + * @param {number} tenantId + * @returns {Promise} + */ + async meta(tenantId: number): Promise { + const tenantMetadata = await TenantMetadata.query().findOne({ tenantId }); + + const organizationName = tenantMetadata.name; + const baseCurrency = tenantMetadata.baseCurrency; + const dateFormat = tenantMetadata.dateFormat; + + const isCostComputeRunning = + this.inventoryService.isItemsCostComputeRunning(tenantId); + + return { + organizationName, + baseCurrency, + dateFormat, + isCostComputeRunning, + sheetName: '', + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/FinancialSheetStructure.ts b/packages/server/src/services/FinancialStatements/FinancialSheetStructure.ts index 722635d9a..5e2fbba55 100644 --- a/packages/server/src/services/FinancialStatements/FinancialSheetStructure.ts +++ b/packages/server/src/services/FinancialStatements/FinancialSheetStructure.ts @@ -61,14 +61,14 @@ export const FinancialSheetStructure = (Base: Class) => }); }; - findNodeDeep = (nodes, callback) => { + public findNodeDeep = (nodes, callback) => { return findValueDeep(nodes, callback, { childrenPath: 'children', pathFormat: 'array', }); }; - mapAccNodesDeep = (nodes, callback) => { + public mapAccNodesDeep = (nodes, callback) => { return reduceDeep( nodes, (acc, value, key, parentValue, context) => { @@ -97,11 +97,11 @@ export const FinancialSheetStructure = (Base: Class) => }); }; - getTotalOfChildrenNodes = (node) => { + public getTotalOfChildrenNodes = (node) => { return this.getTotalOfNodes(node.children); }; - getTotalOfNodes = (nodes) => { + public getTotalOfNodes = (nodes) => { return sumBy(nodes, 'total.amount'); }; }; diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedger.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedger.ts index 512ed37d7..5e12e9078 100644 --- a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedger.ts +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedger.ts @@ -10,6 +10,7 @@ import { IContact, } from '@/interfaces'; import FinancialSheet from '../FinancialSheet'; +import moment from 'moment'; /** * General ledger sheet. @@ -88,8 +89,10 @@ export default class GeneralLedgerSheet extends FinancialSheet { const newEntry = { date: entry.date, + dateFormatted: moment(entry.date).format('YYYY MMM DD'), entryId: entry.id, + transactionNumber: entry.transactionNumber, referenceType: entry.referenceType, referenceId: entry.referenceId, referenceTypeFormatted: this.i18n.__(entry.referenceTypeFormatted), diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerApplication.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerApplication.ts new file mode 100644 index 000000000..6257e34d8 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerApplication.ts @@ -0,0 +1,83 @@ +import { Inject } from 'typedi'; +import { + IGeneralLedgerSheetQuery, + IGeneralLedgerTableData, +} from '@/interfaces'; +import { GeneralLedgerTableInjectable } from './GeneralLedgerTableInjectable'; +import { GeneralLedgerExportInjectable } from './GeneralLedgerExport'; +import { GeneralLedgerService } from './GeneralLedgerService'; +import { GeneralLedgerPdf } from './GeneralLedgerPdf'; + +export class GeneralLedgerApplication { + @Inject() + private GLTable: GeneralLedgerTableInjectable; + + @Inject() + private GLExport: GeneralLedgerExportInjectable; + + @Inject() + private GLSheet: GeneralLedgerService; + + @Inject() + private GLPdf: GeneralLedgerPdf; + + /** + * Retrieves the G/L sheet in json format. + * @param {number} tenantId + * @param {IGeneralLedgerSheetQuery} query + */ + public sheet(tenantId: number, query: IGeneralLedgerSheetQuery) { + return this.GLSheet.generalLedger(tenantId, query); + } + + /** + * Retrieves the G/L sheet in table format. + * @param {number} tenantId + * @param {IGeneralLedgerSheetQuery} query + * @returns {Promise} + */ + public table( + tenantId: number, + query: IGeneralLedgerSheetQuery + ): Promise { + return this.GLTable.table(tenantId, query); + } + + /** + * Retrieves the G/L sheet in xlsx format. + * @param {number} tenantId + * @param {IGeneralLedgerSheetQuery} query + * @returns {} + */ + public xlsx( + tenantId: number, + query: IGeneralLedgerSheetQuery + ): Promise { + return this.GLExport.xlsx(tenantId, query); + } + + /** + * Retrieves the G/L sheet in csv format. + * @param {number} tenantId - + * @param {IGeneralLedgerSheetQuery} query - + */ + public csv( + tenantId: number, + query: IGeneralLedgerSheetQuery + ): Promise { + return this.GLExport.csv(tenantId, query); + } + + /** + * Retrieves the G/L sheet in pdf format. + * @param {number} tenantId + * @param {IGeneralLedgerSheetQuery} query + * @returns {Promise} + */ + public pdf( + tenantId: number, + query: IGeneralLedgerSheetQuery + ): Promise { + return this.GLPdf.pdf(tenantId, query); + } +} diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerExport.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerExport.ts new file mode 100644 index 000000000..f05c817c2 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerExport.ts @@ -0,0 +1,43 @@ +import { IGeneralLedgerSheetQuery } from '@/interfaces'; +import { TableSheet } from '@/lib/Xlsx/TableSheet'; +import { Inject, Service } from 'typedi'; +import { GeneralLedgerTableInjectable } from './GeneralLedgerTableInjectable'; + +@Service() +export class GeneralLedgerExportInjectable { + @Inject() + private generalLedgerTable: GeneralLedgerTableInjectable; + + /** + * Retrieves the general ledger sheet in XLSX format. + * @param {number} tenantId + * @param {IGeneralLedgerSheetQuery} query + * @returns {Promise} + */ + public async xlsx(tenantId: number, query: IGeneralLedgerSheetQuery) { + const table = await this.generalLedgerTable.table(tenantId, query); + + const tableSheet = new TableSheet(table.table); + const tableCsv = tableSheet.convertToXLSX(); + + return tableSheet.convertToBuffer(tableCsv, 'xlsx'); + } + + /** + * Retrieves the general ledger sheet in CSV format. + * @param {number} tenantId + * @param {IGeneralLedgerSheetQuery} query + * @returns {Promise} + */ + public async csv( + tenantId: number, + query: IGeneralLedgerSheetQuery + ): Promise { + const table = await this.generalLedgerTable.table(tenantId, query); + + const tableSheet = new TableSheet(table.table); + const tableCsv = tableSheet.convertToCSV(); + + return tableCsv; + } +} diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerMeta.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerMeta.ts new file mode 100644 index 000000000..00f312654 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerMeta.ts @@ -0,0 +1,33 @@ +import { IGeneralLedgerMeta, IGeneralLedgerSheetQuery } from '@/interfaces'; +import moment from 'moment'; +import { Inject, Service } from 'typedi'; +import { FinancialSheetMeta } from '../FinancialSheetMeta'; + +@Service() +export class GeneralLedgerMeta { + @Inject() + private financialSheetMeta: FinancialSheetMeta; + + /** + * Retrieve the balance sheet meta. + * @param {number} tenantId - + * @returns {IBalanceSheetMeta} + */ + public async meta( + tenantId: number, + query: IGeneralLedgerSheetQuery + ): Promise { + const commonMeta = await this.financialSheetMeta.meta(tenantId); + 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 { + ...commonMeta, + sheetName: 'Balance Sheet', + formattedFromDate, + formattedToDate, + formattedDateRange, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerPdf.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerPdf.ts new file mode 100644 index 000000000..efe082709 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerPdf.ts @@ -0,0 +1,35 @@ +import { Inject, Service } from 'typedi'; +import { TableSheetPdf } from '../TableSheetPdf'; +import { GeneralLedgerTableInjectable } from './GeneralLedgerTableInjectable'; +import { IGeneralLedgerSheetQuery } from '@/interfaces'; +import { HtmlTableCustomCss } from './constants'; + +@Service() +export class GeneralLedgerPdf { + @Inject() + private generalLedgerTable: GeneralLedgerTableInjectable; + + @Inject() + private tableSheetPdf: TableSheetPdf; + + /** + * Converts the general ledger sheet table to pdf. + * @param {number} tenantId - Tenant ID. + * @param {IGeneralLedgerSheetQuery} query - + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: IGeneralLedgerSheetQuery + ): Promise { + const table = await this.generalLedgerTable.table(tenantId, query); + + return this.tableSheetPdf.convertToPdf( + tenantId, + table.table, + table.meta.sheetName, + table.meta.formattedDateRange, + HtmlTableCustomCss + ); + } +} diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerService.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerService.ts index 012a05b94..53451d2d8 100644 --- a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerService.ts +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerService.ts @@ -6,24 +6,21 @@ import { IGeneralLedgerSheetQuery, IGeneralLedgerMeta } from '@/interfaces'; import TenancyService from '@/services/Tenancy/TenancyService'; import Journal from '@/services/Accounting/JournalPoster'; import GeneralLedgerSheet from '@/services/FinancialStatements/GeneralLedger/GeneralLedger'; -import InventoryService from '@/services/Inventory/Inventory'; -import { transformToMap, parseBoolean } from 'utils'; +import { transformToMap } from 'utils'; import { Tenant } from '@/system/models'; +import { GeneralLedgerMeta } from './GeneralLedgerMeta'; const ERRORS = { ACCOUNTS_NOT_FOUND: 'ACCOUNTS_NOT_FOUND', }; @Service() -export default class GeneralLedgerService { +export class GeneralLedgerService { @Inject() - tenancy: TenancyService; + private tenancy: TenancyService; @Inject() - inventoryService: InventoryService; - - @Inject('logger') - logger: any; + private generalLedgerMeta: GeneralLedgerMeta; /** * Defaults general ledger report filter query. @@ -59,36 +56,8 @@ export default class GeneralLedgerService { } } - /** - * Retrieve the balance sheet meta. - * @param {number} tenantId - - * @returns {IGeneralLedgerMeta} - */ - reportMetadata(tenantId: number): IGeneralLedgerMeta { - const settings = this.tenancy.settings(tenantId); - - const isCostComputeRunning = this.inventoryService - .isItemsCostComputeRunning(tenantId); - - const organizationName = settings.get({ - group: 'organization', - key: 'name', - }); - const baseCurrency = settings.get({ - group: 'organization', - key: 'base_currency', - }); - - return { - isCostComputeRunning: parseBoolean(isCostComputeRunning, false), - organizationName, - baseCurrency - }; - } - /** * Retrieve general ledger report statement. - * ---------- * @param {number} tenantId * @param {IGeneralLedgerSheetQuery} query * @return {IGeneralLedgerStatement} @@ -99,13 +68,10 @@ export default class GeneralLedgerService { ): Promise<{ data: any; query: IGeneralLedgerSheetQuery; - meta: IGeneralLedgerMeta + meta: IGeneralLedgerMeta; }> { - const { - accountRepository, - transactionsRepository, - contactRepository - } = this.tenancy.repositories(tenantId); + const { accountRepository, transactionsRepository, contactRepository } = + this.tenancy.repositories(tenantId); const i18n = this.tenancy.i18n(tenantId); @@ -129,13 +95,13 @@ export default class GeneralLedgerService { const transactions = await transactionsRepository.journal({ fromDate: filter.fromDate, toDate: filter.toDate, - branchesIds: filter.branchesIds + branchesIds: filter.branchesIds, }); // Retreive opening balance credit/debit sumation. const openingBalanceTrans = await transactionsRepository.journal({ toDate: moment(filter.fromDate).subtract(1, 'day'), sumationCreditDebit: true, - branchesIds: filter.branchesIds + branchesIds: filter.branchesIds, }); // Transform array transactions to journal collection. const transactionsJournal = Journal.fromTransactions( @@ -143,7 +109,7 @@ export default class GeneralLedgerService { tenantId, accountsGraph ); - // Accounts opening transactions. + // Accounts opening transactions. const openingTransJournal = Journal.fromTransactions( openingBalanceTrans, tenantId, @@ -163,10 +129,13 @@ export default class GeneralLedgerService { // Retrieve general ledger report data. const reportData = generalLedgerInstance.reportData(); + // Retrieve general ledger report metadata. + const meta = await this.generalLedgerMeta.meta(tenantId, filter); + return { data: reportData, query: filter, - meta: this.reportMetadata(tenantId), + meta, }; } } diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTable.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTable.ts new file mode 100644 index 000000000..1820ab095 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTable.ts @@ -0,0 +1,256 @@ +import * as R from 'ramda'; +import { + IColumnMapperMeta, + IGeneralLedgerMeta, + IGeneralLedgerSheetAccount, + IGeneralLedgerSheetAccountTransaction, + IGeneralLedgerSheetData, + IGeneralLedgerSheetQuery, + ITableColumn, + ITableColumnAccessor, + ITableRow, +} from '@/interfaces'; +import FinancialSheet from '../FinancialSheet'; +import { FinancialSheetStructure } from '../FinancialSheetStructure'; +import { FinancialTable } from '../FinancialTable'; +import { tableRowMapper } from '@/utils'; +import { ROW_TYPE } from './utils'; + +export class GeneralLedgerTable extends R.compose( + FinancialTable, + FinancialSheetStructure +)(FinancialSheet) { + private data: IGeneralLedgerSheetData; + private query: IGeneralLedgerSheetQuery; + private meta: IGeneralLedgerMeta; + + /** + * Creates an instance of `GeneralLedgerTable`. + * @param {IGeneralLedgerSheetData} data + * @param {IGeneralLedgerSheetQuery} query + */ + constructor( + data: IGeneralLedgerSheetData, + query: IGeneralLedgerSheetQuery, + meta: IGeneralLedgerMeta + ) { + super(); + + this.data = data; + this.query = query; + this.meta = meta; + } + + /** + * Retrieves the common table accessors. + * @returns {ITableColumnAccessor[]} + */ + private accountColumnsAccessors(): ITableColumnAccessor[] { + return [ + { key: 'date', accessor: 'name' }, + { key: 'account_name', accessor: '_empty_' }, + { key: 'reference_type', accessor: '_empty_' }, + { key: 'reference_number', accessor: '_empty_' }, + { key: 'description', accessor: 'description' }, + { key: 'credit', accessor: '_empty_' }, + { key: 'debit', accessor: '_empty_' }, + { key: 'amount', accessor: 'amount.formattedAmount' }, + { key: 'running_balance', accessor: 'closingBalance.formattedAmount' }, + ]; + } + + /** + * Retrieves the transaction column accessors. + * @returns {ITableColumnAccessor[]} + */ + private transactionColumnAccessors(): ITableColumnAccessor[] { + return [ + { key: 'date', accessor: 'dateFormatted' }, + { key: 'account_name', accessor: 'account.name' }, + { key: 'reference_type', accessor: 'referenceTypeFormatted' }, + { key: 'reference_number', accessor: 'transactionNumber' }, + { key: 'description', accessor: 'note' }, + { key: 'credit', accessor: 'formattedCredit' }, + { key: 'debit', accessor: 'formattedDebit' }, + { key: 'amount', accessor: 'formattedAmount' }, + { key: 'running_balance', accessor: 'formattedRunningBalance' }, + ]; + } + + /** + * Retrieves the opening row column accessors. + * @returns {ITableRowIColumnMapperMeta[]} + */ + private openingBalanceColumnsAccessors(): IColumnMapperMeta[] { + return [ + { key: 'date', value: this.meta.fromDate }, + { key: 'account_name', value: 'Opening Balance' }, + { key: 'reference_type', accessor: '_empty_' }, + { key: 'reference_number', accessor: '_empty_' }, + { key: 'description', accessor: 'description' }, + { key: 'credit', accessor: '_empty_' }, + { key: 'debit', accessor: '_empty_' }, + { key: 'amount', accessor: 'openingBalance.formattedAmount' }, + { key: 'running_balance', accessor: 'openingBalance.formattedAmount' }, + ]; + } + + /** + * Closing balance row column accessors. + * @returns {ITableColumnAccessor[]} + */ + private closingBalanceColumnAccessors(): IColumnMapperMeta[] { + return [ + { key: 'date', value: this.meta.toDate }, + { key: 'account_name', value: 'Closing Balance' }, + { key: 'reference_type', accessor: '_empty_' }, + { key: 'reference_number', accessor: '_empty_' }, + { key: 'description', accessor: '_empty_' }, + { key: 'credit', accessor: '_empty_' }, + { key: 'debit', accessor: '_empty_' }, + { key: 'amount', accessor: 'closingBalance.formattedAmount' }, + { key: 'running_balance', accessor: 'closingBalance.formattedAmount' }, + ]; + } + + /** + * Retrieves the common table columns. + * @returns {ITableColumn[]} + */ + private commonColumns(): ITableColumn[] { + return [ + { key: 'date', label: 'Date' }, + { key: 'account_name', label: 'Account Name' }, + { key: 'reference_type', label: 'Transaction Type' }, + { key: 'reference_number', label: 'Transaction #' }, + { key: 'description', label: 'Description' }, + { key: 'credit', label: 'Credit' }, + { key: 'debit', label: 'Debit' }, + { key: 'amount', label: 'Amount' }, + { key: 'running_balance', label: 'Running Balance' }, + ]; + } + + /** + * Maps the given transaction node to table row. + * @param {IGeneralLedgerSheetAccountTransaction} transaction + * @returns {ITableRow} + */ + private transactionMapper = R.curry( + ( + account: IGeneralLedgerSheetAccount, + transaction: IGeneralLedgerSheetAccountTransaction + ): ITableRow => { + const columns = this.transactionColumnAccessors(); + const data = { ...transaction, account }; + const meta = { + rowTypes: [ROW_TYPE.TRANSACTION], + }; + return tableRowMapper(data, columns, meta); + } + ); + + /** + * Maps the given transactions nodes to table rows. + * @param {IGeneralLedgerSheetAccountTransaction[]} transactions + * @returns {ITableRow[]} + */ + private transactionsMapper = ( + account: IGeneralLedgerSheetAccount + ): ITableRow[] => { + const transactionMapper = this.transactionMapper(account); + + return R.map(transactionMapper)(account.transactions); + }; + + /** + * Maps the given account node to opening balance table row. + * @param {IGeneralLedgerSheetAccount} account + * @returns {ITableRow} + */ + private openingBalanceMapper = ( + account: IGeneralLedgerSheetAccount + ): ITableRow => { + const columns = this.openingBalanceColumnsAccessors(); + const meta = { + rowTypes: [ROW_TYPE.OPENING_BALANCE], + }; + return tableRowMapper(account, columns, meta); + }; + + /** + * Maps the given account node to closing balance table row. + * @param {IGeneralLedgerSheetAccount} account + * @returns {ITableRow} + */ + private closingBalanceMapper = (account: IGeneralLedgerSheetAccount) => { + const columns = this.closingBalanceColumnAccessors(); + const meta = { + rowTypes: [ROW_TYPE.CLOSING_BALANCE], + }; + return tableRowMapper(account, columns, meta); + }; + + /** + * Maps the given account node to transactions table rows. + * @param {IGeneralLedgerSheetAccount} account + * @returns {ITableRow[]} + */ + private transactionsNode = ( + account: IGeneralLedgerSheetAccount + ): ITableRow[] => { + const openingBalance = this.openingBalanceMapper(account); + const transactions = this.transactionsMapper(account); + const closingBalance = this.closingBalanceMapper(account); + + return R.when( + R.always(R.not(R.isEmpty(transactions))), + R.prepend(openingBalance) + )([...transactions, closingBalance]) as ITableRow[]; + }; + + /** + * Maps the given account node to the table rows. + * @param {IGeneralLedgerSheetAccount} account + * @returns {ITableRow} + */ + private accountMapper = (account: IGeneralLedgerSheetAccount): ITableRow => { + const columns = this.accountColumnsAccessors(); + const transactions = this.transactionsNode(account); + const meta = { + rowTypes: [ROW_TYPE.ACCOUNT], + }; + const row = tableRowMapper(account, columns, meta); + + return R.assoc('children', transactions)(row); + }; + + /** + * Maps the given account node to table rows. + * @param {IGeneralLedgerSheetAccount[]} accounts + * @returns {ITableRow[]} + */ + private accountsMapper = ( + accounts: IGeneralLedgerSheetAccount[] + ): ITableRow[] => { + return this.mapNodesDeep(accounts, this.accountMapper); + }; + + /** + * Retrieves the table rows. + * @returns {ITableRow[]} + */ + public tableRows(): ITableRow[] { + return R.compose(this.accountsMapper)(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/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTableInjectable.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTableInjectable.ts new file mode 100644 index 000000000..c830c29c2 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTableInjectable.ts @@ -0,0 +1,45 @@ +import { + IGeneralLedgerSheetQuery, + IGeneralLedgerTableData, +} from '@/interfaces'; +import { Inject, Service } from 'typedi'; +import { GeneralLedgerService } from './GeneralLedgerService'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { GeneralLedgerTable } from './GeneralLedgerTable'; + +@Service() +export class GeneralLedgerTableInjectable { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private GLSheet: GeneralLedgerService; + + /** + * Retrieves the G/L table. + * @param {number} tenantId + * @param {IGeneralLedgerSheetQuery} query + * @returns {Promise} + */ + public async table( + tenantId: number, + query: IGeneralLedgerSheetQuery + ): Promise { + const { + data: sheetData, + query: sheetQuery, + meta: sheetMeta, + } = await this.GLSheet.generalLedger(tenantId, query); + + const table = new GeneralLedgerTable(sheetData, sheetQuery, sheetMeta); + + return { + table: { + columns: table.tableColumns(), + rows: table.tableRows(), + }, + query: sheetQuery, + meta: sheetMeta, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/constants.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/constants.ts new file mode 100644 index 000000000..9e79a81da --- /dev/null +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/constants.ts @@ -0,0 +1,29 @@ +export const HtmlTableCustomCss = ` +table tr:last-child td { + border-bottom: 1px solid #ececec; +} +table tr.row-type--account td, +table tr.row-type--opening-balance td, +table tr.row-type--closing-balance td{ + font-weight: 600; +} +table tr.row-type--closing-balance td { + border-bottom: 1px solid #ececec; +} + +table .column--debit, +table .column--credit, +table .column--amount, +table .column--running_balance, +table .cell--debit, +table .cell--credit, +table .cell--amount, +table .cell--running_balance{ + text-align: right; +} +table tr.row-type--account .cell--date span, +table tr.row-type--opening-balance .cell--account_name span, +table tr.row-type--closing-balance .cell--account_name span{ + white-space: nowrap; +} +`; \ No newline at end of file diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/utils.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/utils.ts new file mode 100644 index 000000000..07418ae37 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/utils.ts @@ -0,0 +1,6 @@ +export enum ROW_TYPE { + ACCOUNT = 'ACCOUNT', + OPENING_BALANCE = 'OPENING_BALANCE', + TRANSACTION = 'TRANSACTION', + CLOSING_BALANCE = 'CLOSING_BALANCE', +} diff --git a/packages/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsApplication.ts b/packages/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsApplication.ts index a2639094e..50cbd2a2f 100644 --- a/packages/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsApplication.ts +++ b/packages/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsApplication.ts @@ -6,6 +6,7 @@ import { Inject, Service } from 'typedi'; import { InventoryDetailsExportInjectable } from './InventoryDetailsExportInjectable'; import { InventoryDetailsTableInjectable } from './InventoryDetailsTableInjectable'; import { InventoryDetailsService } from './InventoryDetailsService'; +import { InventoryDetailsTablePdf } from './InventoryDetailsTablePdf'; @Service() export class InventortyDetailsApplication { @@ -18,6 +19,9 @@ export class InventortyDetailsApplication { @Inject() private inventoryDetails: InventoryDetailsService; + @Inject() + private inventoryDetailsPdf: InventoryDetailsTablePdf; + /** * Retrieves the inventory details report in sheet format. * @param {number} tenantId @@ -63,4 +67,14 @@ export class InventortyDetailsApplication { public csv(tenantId: number, query: IInventoryDetailsQuery): Promise { return this.inventoryDetailsExport.csv(tenantId, query); } + + /** + * Retrieves the inventory details report in PDF format. + * @param {number} tenantId + * @param {IInventoryDetailsQuery} query + * @returns {Promise} + */ + public pdf(tenantId: number, query: IInventoryDetailsQuery) { + return this.inventoryDetailsPdf.pdf(tenantId, query); + } } diff --git a/packages/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsMeta.ts b/packages/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsMeta.ts new file mode 100644 index 000000000..578954833 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsMeta.ts @@ -0,0 +1,35 @@ +import moment from 'moment'; +import { Inject, Service } from 'typedi'; +import { FinancialSheetMeta } from '../FinancialSheetMeta'; +import { IInventoryDetailsQuery, IInventoryItemDetailMeta } from '@/interfaces'; + +@Service() +export class InventoryDetailsMetaInjectable { + @Inject() + private financialSheetMeta: FinancialSheetMeta; + + /** + * Retrieve the inventoy details meta. + * @param {number} tenantId - + * @returns {IBalanceSheetMeta} + */ + public async meta( + tenantId: number, + query: IInventoryDetailsQuery + ): Promise { + const commonMeta = await this.financialSheetMeta.meta(tenantId); + const formattedFromDate = moment(query.fromDate).format('YYYY/MM/DD'); + const formattedToDay = moment(query.toDate).format('YYYY/MM/DD'); + const formattedDateRange = `From ${formattedFromDate} | To ${formattedToDay}`; + + const sheetName = 'Inventory Item Details'; + + return { + ...commonMeta, + sheetName, + formattedFromDate, + formattedToDay, + formattedDateRange, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsService.ts b/packages/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsService.ts index d74137753..e9b6614d6 100644 --- a/packages/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsService.ts +++ b/packages/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsService.ts @@ -1,17 +1,12 @@ import moment from 'moment'; import { Service, Inject } from 'typedi'; -import { - IInventoryDetailsQuery, - IInvetoryItemDetailDOO, - IInventoryItemDetailMeta, -} from '@/interfaces'; +import { IInventoryDetailsQuery, IInvetoryItemDetailDOO } from '@/interfaces'; import TenancyService from '@/services/Tenancy/TenancyService'; import { InventoryDetails } from './InventoryDetails'; import FinancialSheet from '../FinancialSheet'; import InventoryDetailsRepository from './InventoryDetailsRepository'; -import InventoryService from '@/services/Inventory/Inventory'; -import { parseBoolean } from 'utils'; import { Tenant } from '@/system/models'; +import { InventoryDetailsMetaInjectable } from './InventoryDetailsMeta'; @Service() export class InventoryDetailsService extends FinancialSheet { @@ -22,7 +17,7 @@ export class InventoryDetailsService extends FinancialSheet { private reportRepo: InventoryDetailsRepository; @Inject() - private inventoryService: InventoryService; + private inventoryDetailsMeta: InventoryDetailsMetaInjectable; /** * Defaults balance sheet filter query. @@ -46,33 +41,6 @@ export class InventoryDetailsService extends FinancialSheet { }; } - /** - * Retrieve the balance sheet meta. - * @param {number} tenantId - - * @returns {IInventoryItemDetailMeta} - */ - private reportMetadata(tenantId: number): IInventoryItemDetailMeta { - const settings = this.tenancy.settings(tenantId); - - const isCostComputeRunning = - this.inventoryService.isItemsCostComputeRunning(tenantId); - - const organizationName = settings.get({ - group: 'organization', - key: 'name', - }); - const baseCurrency = settings.get({ - group: 'organization', - key: 'base_currency', - }); - - return { - isCostComputeRunning: parseBoolean(isCostComputeRunning, false), - organizationName, - baseCurrency, - }; - } - /** * Retrieve the inventory details report data. * @param {number} tenantId - @@ -115,11 +83,12 @@ export class InventoryDetailsService extends FinancialSheet { tenant.metadata.baseCurrency, i18n ); + const meta = await this.inventoryDetailsMeta.meta(tenantId, query); return { data: inventoryDetailsInstance.reportData(), query: filter, - meta: this.reportMetadata(tenantId), + meta, }; } } diff --git a/packages/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsTablePdf.ts b/packages/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsTablePdf.ts new file mode 100644 index 000000000..b14b11c80 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsTablePdf.ts @@ -0,0 +1,35 @@ +import { Inject, Service } from 'typedi'; +import { InventoryDetailsTableInjectable } from './InventoryDetailsTableInjectable'; +import { TableSheetPdf } from '../TableSheetPdf'; +import { IInventoryDetailsQuery } from '@/interfaces'; +import { HtmlTableCustomCss } from './constant'; + +@Service() +export class InventoryDetailsTablePdf { + @Inject() + private inventoryDetailsTable: InventoryDetailsTableInjectable; + + @Inject() + private tableSheetPdf: TableSheetPdf; + + /** + * Converts the given inventory details sheet table to pdf. + * @param {number} tenantId - Tenant ID. + * @param {IBalanceSheetQuery} query - Balance sheet query. + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: IInventoryDetailsQuery + ): Promise { + const table = await this.inventoryDetailsTable.table(tenantId, query); + + return this.tableSheetPdf.convertToPdf( + tenantId, + table.table, + table.meta.sheetName, + table.meta.formattedDateRange, + HtmlTableCustomCss + ); + } +} diff --git a/packages/server/src/services/FinancialStatements/InventoryDetails/constant.ts b/packages/server/src/services/FinancialStatements/InventoryDetails/constant.ts new file mode 100644 index 000000000..eeb199236 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/InventoryDetails/constant.ts @@ -0,0 +1,7 @@ +export const HtmlTableCustomCss = ` +table tr.row-type--item td, +table tr.row-type--opening-entry td, +table tr.row-type--closing-entry td{ + font-weight: 500; +} +`; diff --git a/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheet.ts b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheet.ts index 693ee2692..fa27707e1 100644 --- a/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheet.ts +++ b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheet.ts @@ -11,7 +11,7 @@ import { } from '@/interfaces'; import { allPassedConditionsPass, transformToMap } from 'utils'; -export default class InventoryValuationSheet extends FinancialSheet { +export class InventoryValuationSheet extends FinancialSheet { readonly query: IInventoryValuationReportQuery; readonly items: IItem[]; readonly INInventoryCostLots: Map; @@ -259,6 +259,6 @@ export default class InventoryValuationSheet extends FinancialSheet { const items = this.itemsSection(); const total = this.totalSection(items); - return items.length > 0 ? { items, total } : {}; + return { items, total }; } } diff --git a/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetApplication.ts b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetApplication.ts new file mode 100644 index 000000000..7c4a65967 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetApplication.ts @@ -0,0 +1,93 @@ +import { + IInventoryValuationReportQuery, + IInventoryValuationSheet, + IInventoryValuationTable, +} from '@/interfaces'; +import { Inject, Service } from 'typedi'; +import { InventoryValuationSheetService } from './InventoryValuationSheetService'; +import { InventoryValuationSheetTableInjectable } from './InventoryValuationSheetTableInjectable'; +import { InventoryValuationSheetExportable } from './InventoryValuationSheetExportable'; +import { InventoryValuationSheetPdf } from './InventoryValuationSheetPdf'; + +@Service() +export class InventoryValuationSheetApplication { + @Inject() + private inventoryValuationSheet: InventoryValuationSheetService; + + @Inject() + private inventoryValuationTable: InventoryValuationSheetTableInjectable; + + @Inject() + private inventoryValuationExport: InventoryValuationSheetExportable; + + @Inject() + private inventoryValuationPdf: InventoryValuationSheetPdf; + + /** + * Retrieves the inventory valuation json format. + * @param {number} tenantId + * @param {IInventoryValuationReportQuery} query + * @returns + */ + public sheet( + tenantId: number, + query: IInventoryValuationReportQuery + ): Promise { + return this.inventoryValuationSheet.inventoryValuationSheet( + tenantId, + query + ); + } + + /** + * Retrieves the inventory valuation json table format. + * @param {number} tenantId + * @param {IInventoryValuationReportQuery} query + * @returns {Promise} + */ + public table( + tenantId: number, + query: IInventoryValuationReportQuery + ): Promise { + return this.inventoryValuationTable.table(tenantId, query); + } + + /** + * Retrieves the inventory valuation xlsx format. + * @param {number} tenantId + * @param {IInventoryValuationReportQuery} query + * @returns + */ + public xlsx( + tenantId: number, + query: IInventoryValuationReportQuery + ): Promise { + return this.inventoryValuationExport.xlsx(tenantId, query); + } + + /** + * Retrieves the inventory valuation csv format. + * @param {number} tenantId + * @param {IInventoryValuationReportQuery} query + * @returns + */ + public csv( + tenantId: number, + query: IInventoryValuationReportQuery + ): Promise { + return this.inventoryValuationExport.csv(tenantId, query); + } + + /** + * Retrieves the inventory valuation pdf format. + * @param {number} tenantId + * @param {IInventoryValuationReportQuery} query + * @returns {Promise} + */ + public pdf( + tenantId: number, + query: IInventoryValuationReportQuery + ): Promise { + return this.inventoryValuationPdf.pdf(tenantId, query); + } +} diff --git a/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetExportable.ts b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetExportable.ts new file mode 100644 index 000000000..2403ed16b --- /dev/null +++ b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetExportable.ts @@ -0,0 +1,46 @@ +import { Inject, Service } from 'typedi'; +import { IInventoryValuationReportQuery } from '@/interfaces'; +import { InventoryValuationSheetTableInjectable } from './InventoryValuationSheetTableInjectable'; +import { TableSheet } from '@/lib/Xlsx/TableSheet'; + +@Service() +export class InventoryValuationSheetExportable { + @Inject() + private inventoryValuationTable: InventoryValuationSheetTableInjectable; + + /** + * Retrieves the trial balance sheet in XLSX format. + * @param {number} tenantId + * @param {IInventoryValuationReportQuery} query + * @returns {Promise} + */ + public async xlsx( + tenantId: number, + query: IInventoryValuationReportQuery + ): Promise { + const table = await this.inventoryValuationTable.table(tenantId, 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 {number} tenantId + * @param {IInventoryValuationReportQuery} query + * @returns {Promise} + */ + public async csv( + tenantId: number, + query: IInventoryValuationReportQuery + ): Promise { + const table = await this.inventoryValuationTable.table(tenantId, query); + + const tableSheet = new TableSheet(table.table); + const tableCsv = tableSheet.convertToCSV(); + + return tableCsv; + } +} diff --git a/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetMeta.ts b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetMeta.ts new file mode 100644 index 000000000..822253342 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetMeta.ts @@ -0,0 +1,33 @@ + + +import { Inject, Service } from 'typedi'; +import { FinancialSheetMeta } from '../FinancialSheetMeta'; +import { IBalanceSheetMeta, IBalanceSheetQuery, IInventoryValuationReportQuery } from '@/interfaces'; +import moment from 'moment'; + +@Service() +export class InventoryValuationMetaInjectable { + @Inject() + private financialSheetMeta: FinancialSheetMeta; + + /** + * Retrieve the balance sheet meta. + * @param {number} tenantId - + * @returns {IBalanceSheetMeta} + */ + public async meta( + tenantId: number, + query: IInventoryValuationReportQuery + ): Promise { + const commonMeta = await this.financialSheetMeta.meta(tenantId); + const formattedAsDate = moment(query.asDate).format('YYYY/MM/DD'); + const formattedDateRange = `As ${formattedAsDate}`; + + return { + ...commonMeta, + sheetName: 'Inventory Valuation Sheet', + formattedAsDate, + formattedDateRange, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetPdf.ts b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetPdf.ts new file mode 100644 index 000000000..449cec9cc --- /dev/null +++ b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetPdf.ts @@ -0,0 +1,36 @@ +import { Inject, Service } from "typedi"; +import { InventoryValuationSheetTableInjectable } from "./InventoryValuationSheetTableInjectable"; +import { TableSheetPdf } from "../TableSheetPdf"; +import { IInventoryValuationReportQuery } from "@/interfaces"; +import { HtmlTableCustomCss } from "./_constants"; + + +@Service() +export class InventoryValuationSheetPdf { + @Inject() + private inventoryValuationTable: InventoryValuationSheetTableInjectable; + + @Inject() + private tableSheetPdf: TableSheetPdf; + + /** + * Converts the given balance sheet table to pdf. + * @param {number} tenantId - Tenant ID. + * @param {IBalanceSheetQuery} query - Balance sheet query. + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: IInventoryValuationReportQuery + ): Promise { + const table = await this.inventoryValuationTable.table(tenantId, query); + + return this.tableSheetPdf.convertToPdf( + tenantId, + table.table, + table.meta.sheetName, + table.meta.formattedDateRange, + HtmlTableCustomCss + ); + } +} \ No newline at end of file diff --git a/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetService.ts b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetService.ts index 75c871ce8..4da85b21d 100644 --- a/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetService.ts +++ b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetService.ts @@ -3,15 +3,17 @@ import moment from 'moment'; import { isEmpty } from 'lodash'; import { IInventoryValuationReportQuery, + IInventoryValuationSheet, IInventoryValuationSheetMeta, } from '@/interfaces'; import TenancyService from '@/services/Tenancy/TenancyService'; -import InventoryValuationSheet from './InventoryValuationSheet'; +import { InventoryValuationSheet } from './InventoryValuationSheet'; import InventoryService from '@/services/Inventory/Inventory'; import { Tenant } from '@/system/models'; +import { InventoryValuationMetaInjectable } from './InventoryValuationSheetMeta'; @Service() -export default class InventoryValuationSheetService { +export class InventoryValuationSheetService { @Inject() tenancy: TenancyService; @@ -21,6 +23,9 @@ export default class InventoryValuationSheetService { @Inject() inventoryService: InventoryService; + @Inject() + private inventoryValuationMeta: InventoryValuationMetaInjectable; + /** * Defaults balance sheet filter query. * @return {IBalanceSheetQuery} @@ -45,33 +50,6 @@ export default class InventoryValuationSheetService { }; } - /** - * Retrieve the balance sheet meta. - * @param {number} tenantId - - * @returns {IBalanceSheetMeta} - */ - reportMetadata(tenantId: number): IInventoryValuationSheetMeta { - const settings = this.tenancy.settings(tenantId); - - const isCostComputeRunning = - this.inventoryService.isItemsCostComputeRunning(tenantId); - - const organizationName = settings.get({ - group: 'organization', - key: 'name', - }); - const baseCurrency = settings.get({ - group: 'organization', - key: 'base_currency', - }); - - return { - organizationName, - baseCurrency, - isCostComputeRunning, - }; - } - /** * Inventory valuation sheet. * @param {number} tenantId - Tenant id. @@ -80,7 +58,7 @@ export default class InventoryValuationSheetService { public async inventoryValuationSheet( tenantId: number, query: IInventoryValuationReportQuery - ) { + ): Promise { const { Item, InventoryCostLotTracker } = this.tenancy.models(tenantId); const tenant = await Tenant.query() @@ -135,10 +113,13 @@ export default class InventoryValuationSheetService { // Retrieve the inventory valuation report data. const inventoryValuationData = inventoryValuationInstance.reportData(); + // Retrieves the inventorty valuation meta. + const meta = await this.inventoryValuationMeta.meta(tenantId, filter); + return { data: inventoryValuationData, query: filter, - meta: this.reportMetadata(tenantId), + meta, }; } } diff --git a/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetTable.ts b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetTable.ts new file mode 100644 index 000000000..7e792f6f9 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetTable.ts @@ -0,0 +1,105 @@ +import * as R from 'ramda'; +import { + IInventoryValuationItem, + IInventoryValuationSheetData, + IInventoryValuationTotal, + ITableColumn, + ITableColumnAccessor, + ITableRow, +} from '@/interfaces'; +import { tableRowMapper } from '@/utils'; +import FinancialSheet from '../FinancialSheet'; +import { FinancialSheetStructure } from '../FinancialSheetStructure'; +import { FinancialTable } from '../FinancialTable'; +import { ROW_TYPE } from './_constants'; + +export class InventoryValuationSheetTable extends R.compose( + FinancialTable, + FinancialSheetStructure +)(FinancialSheet) { + private readonly data: IInventoryValuationSheetData; + + /** + * Constructor method. + * @param {IInventoryValuationSheetData} data + */ + constructor(data: IInventoryValuationSheetData) { + super(); + this.data = data; + } + + /** + * Retrieves the common columns accessors. + * @returns {ITableColumnAccessor} + */ + private commonColumnsAccessors(): ITableColumnAccessor[] { + return [ + { key: 'item_name', accessor: 'name' }, + { key: 'quantity', accessor: 'quantityFormatted' }, + { key: 'valuation', accessor: 'valuationFormatted' }, + { key: 'average', accessor: 'averageFormatted' }, + ]; + } + + /** + * Maps the given total node to table row. + * @param {IInventoryValuationTotal} total + * @returns {ITableRow} + */ + private totalRowMapper = (total: IInventoryValuationTotal): ITableRow => { + const accessors = this.commonColumnsAccessors(); + const meta = { + rowTypes: [ROW_TYPE.TOTAL], + }; + return tableRowMapper(total, accessors, meta); + }; + + /** + * Maps the given item node to table row. + * @param {IInventoryValuationItem} item + * @returns {ITableRow} + */ + private itemRowMapper = (item: IInventoryValuationItem): ITableRow => { + const accessors = this.commonColumnsAccessors(); + const meta = { + rowTypes: [ROW_TYPE.ITEM], + }; + return tableRowMapper(item, accessors, meta); + }; + + /** + * Maps the given items nodes to table rowes. + * @param {IInventoryValuationItem[]} items + * @returns {ITableRow[]} + */ + private itemsRowsMapper = (items: IInventoryValuationItem[]): ITableRow[] => { + return R.map(this.itemRowMapper)(items); + }; + + /** + * Retrieves the table rows. + * @returns {ITableRow[]} + */ + public tableRows(): ITableRow[] { + const itemsRows = this.itemsRowsMapper(this.data.items); + const totalRow = this.totalRowMapper(this.data.total); + + return R.compose( + R.when(R.always(R.not(R.isEmpty(itemsRows))), R.append(totalRow)) + )([...itemsRows]) as ITableRow[]; + } + + /** + * Retrieves the table columns. + * @returns {ITableColumn[]} + */ + public tableColumns(): ITableColumn[] { + const columns = [ + { key: 'item_name', label: 'Item Name' }, + { key: 'quantity', label: 'Quantity' }, + { key: 'valuation', label: 'Valuation' }, + { key: 'average', label: 'Average' }, + ]; + return R.compose(this.tableColumnsCellIndexing)(columns); + } +} diff --git a/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetTableInjectable.ts b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetTableInjectable.ts new file mode 100644 index 000000000..cad8126b4 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetTableInjectable.ts @@ -0,0 +1,39 @@ +import { Inject, Service } from 'typedi'; +import { InventoryValuationSheetService } from './InventoryValuationSheetService'; +import { + IInventoryValuationReportQuery, + IInventoryValuationTable, +} from '@/interfaces'; +import { InventoryValuationSheetTable } from './InventoryValuationSheetTable'; + +@Service() +export class InventoryValuationSheetTableInjectable { + @Inject() + private sheet: InventoryValuationSheetService; + + /** + * Retrieves the inventory valuation json table format. + * @param {number} tenantId - + * @param {IInventoryValuationReportQuery} filter - + * @returns {Promise} + */ + public async table( + tenantId: number, + filter: IInventoryValuationReportQuery + ): Promise { + const { data, query, meta } = await this.sheet.inventoryValuationSheet( + tenantId, + filter + ); + const table = new InventoryValuationSheetTable(data); + + return { + table: { + columns: table.tableColumns(), + rows: table.tableRows(), + }, + query, + meta, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/InventoryValuationSheet/_constants.ts b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/_constants.ts new file mode 100644 index 000000000..f41136b6f --- /dev/null +++ b/packages/server/src/services/FinancialStatements/InventoryValuationSheet/_constants.ts @@ -0,0 +1,12 @@ +export enum ROW_TYPE { + ITEM = 'ITEM', + TOTAL = 'TOTAL', +} + +export const HtmlTableCustomCss = ` +table tr.row-type--total td { + border-top: 1px solid #bbb; + font-weight: 600; + border-bottom: 3px double #000; +} +`; diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheet.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheet.ts index ee184a5a1..89725db3b 100644 --- a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheet.ts +++ b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheet.ts @@ -6,8 +6,10 @@ import { IJournalReportQuery, IJournalReport, IContact, + IJournalTableData, } from '@/interfaces'; import FinancialSheet from '../FinancialSheet'; +import moment from 'moment'; export default class JournalSheet extends FinancialSheet { readonly tenantId: number; @@ -96,6 +98,8 @@ export default class JournalSheet extends FinancialSheet { return { date: groupEntry.date, + dateFormatted: moment(groupEntry.date).format('YYYY MMM DD'), + referenceType: groupEntry.referenceType, referenceId: groupEntry.referenceId, referenceTypeFormatted: this.i18n.__(groupEntry.referenceTypeFormatted), @@ -131,7 +135,7 @@ export default class JournalSheet extends FinancialSheet { * Retrieve journal report. * @return {IJournalReport} */ - reportData(): IJournalReport { + reportData(): IJournalTableData { return this.entriesWalker(this.journal.entries); } } diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetApplication.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetApplication.ts new file mode 100644 index 000000000..8d3f5d614 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetApplication.ts @@ -0,0 +1,73 @@ +import { Inject } from 'typedi'; +import { JournalSheetService } from './JournalSheetService'; +import { JournalSheetTableInjectable } from './JournalSheetTableInjectable'; +import { IJournalReportQuery, IJournalTable } from '@/interfaces'; +import { JournalSheetExportInjectable } from './JournalSheetExport'; +import { JournalSheetPdfInjectable } from './JournalSheetPdfInjectable'; + +export class JournalSheetApplication { + @Inject() + private journalSheetTable: JournalSheetTableInjectable; + + @Inject() + private journalSheet: JournalSheetService; + + @Inject() + private journalExport: JournalSheetExportInjectable; + + @Inject() + private journalPdf: JournalSheetPdfInjectable; + + /** + * Retrieves the journal sheet. + * @param {number} tenantId + * @param {IJournalReportQuery} query + * @returns {} + */ + public sheet(tenantId: number, query: IJournalReportQuery) { + return this.journalSheet.journalSheet(tenantId, query); + } + + /** + * Retrieves the journal sheet in table format. + * @param {number} tenantId + * @param {IJournalReportQuery} query + * @returns {Promise} + */ + public table( + tenantId: number, + query: IJournalReportQuery + ): Promise { + return this.journalSheetTable.table(tenantId, query); + } + + /** + * Retrieves the journal sheet in xlsx format. + * @param {number} tenantId + * @param {IJournalReportQuery} query + * @returns + */ + public xlsx(tenantId: number, query: IJournalReportQuery) { + return this.journalExport.xlsx(tenantId, query); + } + + /** + * Retrieves the journal sheet in csv format. + * @param {number} tenantId + * @param {IJournalReportQuery} query + * @returns + */ + public csv(tenantId: number, query: IJournalReportQuery) { + return this.journalExport.csv(tenantId, query); + } + + /** + * Retrieves the journal sheet in pdf format. + * @param {number} tenantId + * @param {IJournalReportQuery} query + * @returns {Promise} + */ + public pdf(tenantId: number, query: IJournalReportQuery) { + return this.journalPdf.pdf(tenantId, query); + } +} diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetExport.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetExport.ts new file mode 100644 index 000000000..815c0a308 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetExport.ts @@ -0,0 +1,43 @@ +import { Inject, Service } from 'typedi'; +import { TableSheet } from '@/lib/Xlsx/TableSheet'; +import { IJournalReportQuery } from '@/interfaces'; +import { JournalSheetTableInjectable } from './JournalSheetTableInjectable'; + +@Service() +export class JournalSheetExportInjectable { + @Inject() + private journalSheetTable: JournalSheetTableInjectable; + + /** + * Retrieves the trial balance sheet in XLSX format. + * @param {number} tenantId + * @param {IJournalReportQuery} query + * @returns {Promise} + */ + public async xlsx(tenantId: number, query: IJournalReportQuery) { + const table = await this.journalSheetTable.table(tenantId, 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 {number} tenantId + * @param {IJournalReportQuery} query + * @returns {Promise} + */ + public async csv( + tenantId: number, + query: IJournalReportQuery + ): Promise { + const table = await this.journalSheetTable.table(tenantId, query); + + const tableSheet = new TableSheet(table.table); + const tableCsv = tableSheet.convertToCSV(); + + return tableCsv; + } +} diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetMeta.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetMeta.ts new file mode 100644 index 000000000..5980e1e70 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetMeta.ts @@ -0,0 +1,34 @@ +import { Service, Inject } from 'typedi'; +import { FinancialSheetMeta } from '../FinancialSheetMeta'; +import moment from 'moment'; +import { IJournalReportQuery, IJournalSheetMeta } from '@/interfaces'; + +@Service() +export class JournalSheetMeta { + @Inject() + private financialSheetMeta: FinancialSheetMeta; + + /** + * Retrieves the journal sheet meta. + * @param {number} tenantId + * @param {IJournalReportQuery} query + * @returns {Promise} + */ + public async meta( + tenantId: number, + query: IJournalReportQuery + ): Promise { + const common = await this.financialSheetMeta.meta(tenantId); + 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/src/services/FinancialStatements/JournalSheet/JournalSheetPdfInjectable.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetPdfInjectable.ts new file mode 100644 index 000000000..6606d6705 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetPdfInjectable.ts @@ -0,0 +1,35 @@ +import { IJournalReportQuery } from '@/interfaces'; +import { TableSheetPdf } from '../TableSheetPdf'; +import { JournalSheetTableInjectable } from './JournalSheetTableInjectable'; +import { Inject, Service } from 'typedi'; +import { HtmlTableCustomCss } from './constant'; + +@Service() +export class JournalSheetPdfInjectable { + @Inject() + private journalSheetTable: JournalSheetTableInjectable; + + @Inject() + private 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( + tenantId: number, + query: IJournalReportQuery + ): Promise { + const table = await this.journalSheetTable.table(tenantId, query); + + return this.tableSheetPdf.convertToPdf( + tenantId, + table.table, + table.meta.sheetName, + table.meta.formattedDateRange, + HtmlTableCustomCss + ); + } +} diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetService.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetService.ts index fe0a071ff..90027c3a1 100644 --- a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetService.ts +++ b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetService.ts @@ -1,24 +1,20 @@ import { Service, Inject } from 'typedi'; import moment from 'moment'; -import { IJournalReportQuery, IJournalSheetMeta } from '@/interfaces'; - +import { IJournalReportQuery, IJournalSheet } from '@/interfaces'; import JournalSheet from './JournalSheet'; import TenancyService from '@/services/Tenancy/TenancyService'; import Journal from '@/services/Accounting/JournalPoster'; -import InventoryService from '@/services/Inventory/Inventory'; -import { parseBoolean, transformToMap } from 'utils'; import { Tenant } from '@/system/models'; +import { transformToMap } from 'utils'; +import { JournalSheetMeta } from './JournalSheetMeta'; @Service() -export default class JournalSheetService { +export class JournalSheetService { @Inject() - tenancy: TenancyService; + private tenancy: TenancyService; @Inject() - inventoryService: InventoryService; - - @Inject('logger') - logger: any; + private journalSheetMeta: JournalSheetMeta; /** * Default journal sheet filter queyr. @@ -37,39 +33,16 @@ export default class JournalSheetService { }; } - /** - * Retrieve the balance sheet meta. - * @param {number} tenantId - - * @returns {IBalanceSheetMeta} - */ - reportMetadata(tenantId: number): IJournalSheetMeta { - const settings = this.tenancy.settings(tenantId); - - const isCostComputeRunning = - this.inventoryService.isItemsCostComputeRunning(tenantId); - - const organizationName = settings.get({ - group: 'organization', - key: 'name', - }); - const baseCurrency = settings.get({ - group: 'organization', - key: 'base_currency', - }); - - return { - isCostComputeRunning: parseBoolean(isCostComputeRunning, false), - organizationName, - baseCurrency, - }; - } - /** * Journal sheet. * @param {number} tenantId - * @param {IJournalSheetFilterQuery} query + * @param {IJournalReportQuery} query + * @returns {Promise} */ - async journalSheet(tenantId: number, query: IJournalReportQuery) { + async journalSheet( + tenantId: number, + query: IJournalReportQuery + ): Promise { const i18n = this.tenancy.i18n(tenantId); const { accountRepository, transactionsRepository, contactRepository } = this.tenancy.repositories(tenantId); @@ -80,11 +53,6 @@ export default class JournalSheetService { ...this.defaultQuery, ...query, }; - this.logger.info('[journal] trying to calculate the report.', { - tenantId, - filter, - }); - const tenant = await Tenant.query() .findById(tenantId) .withGraphFetched('metadata'); @@ -130,10 +98,13 @@ export default class JournalSheetService { // Retrieve journal report columns. const journalSheetData = journalSheetInstance.reportData(); + // Retrieve the journal sheet meta. + const meta = await this.journalSheetMeta.meta(tenantId, filter); + return { data: journalSheetData, query: filter, - meta: this.reportMetadata(tenantId), + meta, }; } } diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTable.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTable.ts new file mode 100644 index 000000000..724e2ec2b --- /dev/null +++ b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTable.ts @@ -0,0 +1,232 @@ +import * as R from 'ramda'; +import { first } from 'lodash'; +import { + IColumnMapperMeta, + IJournalEntry, + IJournalReportEntriesGroup, + IJournalReportQuery, + IJournalTableData, + ITableColumn, + ITableColumnAccessor, + ITableRow, +} from '@/interfaces'; +import { tableRowMapper } from '@/utils'; +import { FinancialTable } from '../FinancialTable'; +import { FinancialSheetStructure } from '../FinancialSheetStructure'; +import FinancialSheet from '../FinancialSheet'; +import { ROW_TYPE } from './types'; + +export class JournalSheetTable extends R.compose( + FinancialTable, + FinancialSheetStructure +)(FinancialSheet) { + private data: IJournalTableData; + private query: IJournalReportQuery; + private i18n: any; + + /** + * Constructor method. + * @param {IJournalTableData} data + * @param {IJournalReportQuery} query + * @param i18n + */ + constructor(data: IJournalTableData, query: IJournalReportQuery, i18n: any) { + 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: 'credit', accessor: 'entry.formattedCredit' }, + { key: 'debit', accessor: 'entry.formattedDebit' }, + ]; + }; + + /** + * 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: 'credit', accessor: 'formattedCredit' }, + { key: 'debit', accessor: 'formattedDebit' }, + ]; + }; + + /** + * 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: 'credit', accessor: 'formattedCredit' }, + { key: 'debit', accessor: 'formattedDebit' }, + ]; + }; + + /** + * 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: 'credit', value: '' }, + { key: 'debit', 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: 'credit', label: 'Credit' }, + { key: 'debit', label: 'Debit' }, + ]; + } + + /** + * 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: IJournalEntry): 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 IJournalEntry, 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/src/services/FinancialStatements/JournalSheet/JournalSheetTableInjectable.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTableInjectable.ts new file mode 100644 index 000000000..0754d78f8 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTableInjectable.ts @@ -0,0 +1,39 @@ +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { Inject } from 'typedi'; +import { JournalSheetService } from './JournalSheetService'; +import { IJournalReportQuery, IJournalTable } from '@/interfaces'; +import { JournalSheetTable } from './JournalSheetTable'; + +export class JournalSheetTableInjectable { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private journalSheetService: JournalSheetService; + + /** + * Retrieves the journal sheet in table format. + * @param {number} tenantId + * @param {IJournalReportQuery} query + * @returns {Promise} + */ + public async table( + tenantId: number, + query: IJournalReportQuery + ): Promise { + const journal = await this.journalSheetService.journalSheet( + tenantId, + query + ); + const table = new JournalSheetTable(journal.data, journal.query, {}); + + return { + table: { + columns: table.tableColumns(), + rows: table.tableData(), + }, + query: journal.query, + meta: journal.meta, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/constant.ts b/packages/server/src/services/FinancialStatements/JournalSheet/constant.ts new file mode 100644 index 000000000..58f5237f6 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/JournalSheet/constant.ts @@ -0,0 +1,17 @@ +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; +} +`; diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/types.ts b/packages/server/src/services/FinancialStatements/JournalSheet/types.ts new file mode 100644 index 000000000..6eff84957 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/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/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheetApplication.ts b/packages/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheetApplication.ts index eee89e73a..6d15e2cc6 100644 --- a/packages/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheetApplication.ts +++ b/packages/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheetApplication.ts @@ -3,6 +3,7 @@ import { ProfitLossSheetExportInjectable } from './ProfitLossSheetExportInjectab import { ProfitLossSheetTableInjectable } from './ProfitLossSheetTableInjectable'; import { IProfitLossSheetQuery, IProfitLossSheetTable } from '@/interfaces'; import ProfitLossSheetService from './ProfitLossSheetService'; +import { ProfitLossTablePdfInjectable } from './ProfitLossTablePdfInjectable'; @Service() export class ProfitLossSheetApplication { @@ -15,6 +16,9 @@ export class ProfitLossSheetApplication { @Inject() private profitLossSheet: ProfitLossSheetService; + @Inject() + private profitLossPdf: ProfitLossTablePdfInjectable; + /** * Retreives the profit/loss sheet. * @param {number} tenantId @@ -57,4 +61,14 @@ export class ProfitLossSheetApplication { public xlsx(tenantId: number, query: IProfitLossSheetQuery): Promise { return this.profitLossExport.xlsx(tenantId, query); } + + /** + * Retrieves the profit/loss sheet in pdf format. + * @param {number} tenantId + * @param {IProfitLossSheetQuery} query + * @returns {Promise} + */ + public pdf(tenantId: number, query: IProfitLossSheetQuery): Promise { + return this.profitLossPdf.pdf(tenantId, query); + } } diff --git a/packages/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheetMeta.ts b/packages/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheetMeta.ts new file mode 100644 index 000000000..c278420f5 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheetMeta.ts @@ -0,0 +1,35 @@ +import moment from 'moment'; +import { Inject, Service } from 'typedi'; +import { FinancialSheetMeta } from '../FinancialSheetMeta'; +import { ICashFlowStatementMeta, ICashFlowStatementQuery } from '@/interfaces'; + +@Service() +export class ProfitLossSheetMeta { + @Inject() + private financialSheetMeta: FinancialSheetMeta; + + /** + * Retrieve the P/L sheet meta. + * @param {number} tenantId - + * @returns {IBalanceSheetMeta} + */ + public async meta( + tenantId: number, + query: ICashFlowStatementQuery + ): Promise { + const commonMeta = await this.financialSheetMeta.meta(tenantId); + 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 = 'Cashflow Statement'; + + return { + ...commonMeta, + sheetName, + formattedFromDate, + formattedToDate, + formattedDateRange, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheetService.ts b/packages/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheetService.ts index 20cf9a170..f3b24e7a6 100644 --- a/packages/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheetService.ts +++ b/packages/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheetService.ts @@ -6,11 +6,10 @@ import { } from '@/interfaces'; import ProfitLossSheet from './ProfitLossSheet'; import TenancyService from '@/services/Tenancy/TenancyService'; -import InventoryService from '@/services/Inventory/Inventory'; -import { parseBoolean } from 'utils'; import { Tenant } from '@/system/models'; import { mergeQueryWithDefaults } from './utils'; import { ProfitLossSheetRepository } from './ProfitLossSheetRepository'; +import { ProfitLossSheetMeta } from './ProfitLossSheetMeta'; // Profit/Loss sheet service. @Service() @@ -19,7 +18,7 @@ export default class ProfitLossSheetService { private tenancy: TenancyService; @Inject() - private inventoryService: InventoryService; + private profitLossSheetMeta: ProfitLossSheetMeta; /** * Retrieve profit/loss sheet statement. @@ -47,6 +46,7 @@ export default class ProfitLossSheetService { const profitLossRepo = new ProfitLossSheetRepository(models, filter); + // Loads the profit/loss sheet data. await profitLossRepo.asyncInitialize(); // Profit/Loss report instance. @@ -57,38 +57,15 @@ export default class ProfitLossSheetService { i18n ); // Profit/loss report data and collumns. - const profitLossData = profitLossInstance.reportData(); + const data = profitLossInstance.reportData(); + + // Retrieve the profit/loss sheet meta. + const meta = await this.profitLossSheetMeta.meta(tenantId, filter); return { - data: profitLossData, query: filter, - meta: this.reportMetadata(tenantId), + data, + meta, }; }; - - /** - * Retrieve the trial balance sheet meta. - * @param {number} tenantId - Tenant id. - * @returns {ITrialBalanceSheetMeta} - */ - private reportMetadata(tenantId: number): IProfitLossSheetMeta { - const settings = this.tenancy.settings(tenantId); - - const isCostComputeRunning = - this.inventoryService.isItemsCostComputeRunning(tenantId); - const organizationName = settings.get({ - group: 'organization', - key: 'name', - }); - const baseCurrency = settings.get({ - group: 'organization', - key: 'base_currency', - }); - - return { - isCostComputeRunning: parseBoolean(isCostComputeRunning, false), - organizationName, - baseCurrency, - }; - } } diff --git a/packages/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossTablePdfInjectable.ts b/packages/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossTablePdfInjectable.ts new file mode 100644 index 000000000..4b0ba23de --- /dev/null +++ b/packages/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossTablePdfInjectable.ts @@ -0,0 +1,35 @@ +import { Inject, Service } from 'typedi'; +import { IProfitLossSheetQuery } from '@/interfaces'; +import { ProfitLossSheetTableInjectable } from './ProfitLossSheetTableInjectable'; +import { TableSheetPdf } from '../TableSheetPdf'; +import { HtmlTableCustomCss } from './constants'; + +@Service() +export class ProfitLossTablePdfInjectable { + @Inject() + private profitLossTable: ProfitLossSheetTableInjectable; + + @Inject() + private tableSheetPdf: TableSheetPdf; + + /** + * Retrieves the profit/loss sheet in pdf format. + * @param {number} tenantId + * @param {number} query + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: IProfitLossSheetQuery + ): Promise { + const table = await this.profitLossTable.table(tenantId, query); + + return this.tableSheetPdf.convertToPdf( + tenantId, + table.table, + table.meta.sheetName, + table.meta.formattedDateRange, + HtmlTableCustomCss + ); + } +} diff --git a/packages/server/src/services/FinancialStatements/ProfitLossSheet/constants.ts b/packages/server/src/services/FinancialStatements/ProfitLossSheet/constants.ts index 56f4a14ba..1ca50c5ed 100644 --- a/packages/server/src/services/FinancialStatements/ProfitLossSheet/constants.ts +++ b/packages/server/src/services/FinancialStatements/ProfitLossSheet/constants.ts @@ -18,4 +18,52 @@ export const TOTAL_NODE_TYPES = [ ProfitLossNodeType.ACCOUNTS, ProfitLossNodeType.AGGREGATE, ProfitLossNodeType.EQUATION -]; \ No newline at end of file +]; + +export const HtmlTableCustomCss =` +table tr.row-type--total td { + font-weight: 600; + border-top: 1px solid #bbb; + color: #000; +} +table tr.row-id--net-income td{ + border-bottom: 3px double #000; +} +table .column--name, +table .cell--name { + width: 400px; +} + +table .column--total { + width: 25%; +} +table td.cell--total, +table td.cell--previous_year, +table td.cell--previous_year_change, +table td.cell--previous_year_percentage, + +table td.cell--previous_period, +table td.cell--previous_period_change, +table td.cell--previous_period_percentage, + +table td.cell--percentage_of_row, +table td.cell--percentage_of_column, +table td[class*="cell--date-range"] { + text-align: right; +} + +table .column--total, +table .column--previous_year, +table .column--previous_year_change, +table .column--previous_year_percentage, + +table .column--previous_period, +table .column--previous_period_change, +table .column--previous_period_percentage, + +table .column--percentage_of_row, +table .column--percentage_of_column, +table [class*="column--date-range"] { + text-align: right; +} +`; \ No newline at end of file diff --git a/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItems.ts b/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItems.ts index 0dba04f03..b679334c6 100644 --- a/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItems.ts +++ b/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItems.ts @@ -2,36 +2,34 @@ import { get, isEmpty, sumBy } from 'lodash'; import * as R from 'ramda'; import FinancialSheet from '../FinancialSheet'; import { allPassedConditionsPass, transformToMap } from 'utils'; +import { IAccountTransaction, IItem } from '@/interfaces'; import { - IAccountTransaction, - IInventoryValuationTotal, - IInventoryValuationItem, - IInventoryValuationReportQuery, - IInventoryValuationStatement, - IItem, -} from '@/interfaces'; + IPurchasesByItemsItem, + IPurchasesByItemsReportQuery, + IPurchasesByItemsSheetData, + IPurchasesByItemsTotal, +} from '@/interfaces/PurchasesByItemsSheet'; -export default class InventoryValuationReport extends FinancialSheet { +export class PurchasesByItems extends FinancialSheet { readonly baseCurrency: string; readonly items: IItem[]; readonly itemsTransactions: Map; - readonly query: IInventoryValuationReportQuery; + readonly query: IPurchasesByItemsReportQuery; /** * Constructor method. - * @param {IInventoryValuationReportQuery} query + * @param {IPurchasesByItemsReportQuery} query * @param {IItem[]} items * @param {IAccountTransaction[]} itemsTransactions * @param {string} baseCurrency */ constructor( - query: IInventoryValuationReportQuery, + query: IPurchasesByItemsReportQuery, items: IItem[], itemsTransactions: IAccountTransaction[], baseCurrency: string ) { super(); - this.baseCurrency = baseCurrency; this.items = items; this.itemsTransactions = transformToMap(itemsTransactions, 'itemId'); @@ -98,7 +96,7 @@ export default class InventoryValuationReport extends FinancialSheet { * @param {IInventoryValuationItem} item * @returns */ - private itemSectionMapper = (item: IItem): IInventoryValuationItem => { + private itemSectionMapper = (item: IItem): IPurchasesByItemsItem => { const meta = this.getItemTransaction(item.id); return { @@ -145,9 +143,9 @@ export default class InventoryValuationReport extends FinancialSheet { /** * Retrieve the items sections. - * @returns {IInventoryValuationItem[]} + * @returns {IPurchasesByItemsItem[]} */ - private itemsSection = (): IInventoryValuationItem[] => { + private itemsSection = (): IPurchasesByItemsItem[] => { return R.compose( R.when(this.isItemsPostFilter, this.itemsFilter), this.itemsMapper @@ -156,10 +154,10 @@ export default class InventoryValuationReport extends FinancialSheet { /** * Retrieve the total section of the sheet. - * @param {IInventoryValuationItem[]} items - * @returns {IInventoryValuationTotal} + * @param {IPurchasesByItemsItem[]} items + * @returns {IPurchasesByItemsTotal} */ - totalSection(items: IInventoryValuationItem[]): IInventoryValuationTotal { + private totalSection(items: IPurchasesByItemsItem[]): IPurchasesByItemsTotal { const quantityPurchased = sumBy(items, (item) => item.quantityPurchased); const purchaseCost = sumBy(items, (item) => item.purchaseCost); @@ -176,12 +174,12 @@ export default class InventoryValuationReport extends FinancialSheet { /** * Retrieve the sheet data. - * @returns + * @returns {IInventoryValuationStatement} */ - reportData(): IInventoryValuationStatement { + public reportData(): IPurchasesByItemsSheetData { const items = this.itemsSection(); const total = this.totalSection(items); - return items.length > 0 ? { items, total } : {}; + return { items, total }; } } diff --git a/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsApplication.ts b/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsApplication.ts new file mode 100644 index 000000000..eeecaff38 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsApplication.ts @@ -0,0 +1,90 @@ +import { Service, Inject } from 'typedi'; +import { PurchasesByItemsExport } from './PurchasesByItemsExport'; +import { + IPurchasesByItemsReportQuery, + IPurchasesByItemsSheet, + IPurchasesByItemsTable, +} from '@/interfaces/PurchasesByItemsSheet'; +import { PurchasesByItemsTableInjectable } from './PurchasesByItemsTableInjectable'; +import { PurchasesByItemsService } from './PurchasesByItemsService'; +import { PurchasesByItemsPdf } from './PurchasesByItemsPdf'; + +@Service() +export class PurcahsesByItemsApplication { + @Inject() + private purchasesByItemsSheet: PurchasesByItemsService; + + @Inject() + private purchasesByItemsTable: PurchasesByItemsTableInjectable; + + @Inject() + private purchasesByItemsExport: PurchasesByItemsExport; + + @Inject() + private purchasesByItemsPdf: PurchasesByItemsPdf; + + /** + * Retrieves the purchases by items in json format. + * @param {number} tenantId + * @param {IPurchasesByItemsReportQuery} query + * @returns + */ + public sheet( + tenantId: number, + query: IPurchasesByItemsReportQuery + ): Promise { + return this.purchasesByItemsSheet.purchasesByItems(tenantId, query); + } + + /** + * Retrieves the purchases by items in table format. + * @param {number} tenantId + * @param {IPurchasesByItemsReportQuery} query + * @returns {Promise} + */ + public table( + tenantId: number, + query: IPurchasesByItemsReportQuery + ): Promise { + return this.purchasesByItemsTable.table(tenantId, query); + } + + /** + * Retrieves the purchases by items in csv format. + * @param {number} tenantId + * @param {IPurchasesByItemsReportQuery} query + * @returns {Promise} + */ + public csv( + tenantId: number, + query: IPurchasesByItemsReportQuery + ): Promise { + return this.purchasesByItemsExport.csv(tenantId, query); + } + + /** + * Retrieves the purchases by items in xlsx format. + * @param {number} tenantId + * @param {IPurchasesByItemsReportQuery} query + * @returns {Promise} + */ + public xlsx( + tenantId: number, + query: IPurchasesByItemsReportQuery + ): Promise { + return this.purchasesByItemsExport.xlsx(tenantId, query); + } + + /** + * Retrieves the purchases by items in pdf format. + * @param {number} tenantId + * @param {IPurchasesByItemsReportQuery} filter + * @returns {Promise} + */ + public pdf( + tenantId: number, + filter: IPurchasesByItemsReportQuery + ): Promise { + return this.purchasesByItemsPdf.pdf(tenantId, filter); + } +} diff --git a/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsExport.ts b/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsExport.ts new file mode 100644 index 000000000..96ed4daf7 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsExport.ts @@ -0,0 +1,46 @@ +import { Inject, Service } from 'typedi'; +import { TableSheet } from '@/lib/Xlsx/TableSheet'; +import { PurchasesByItemsTableInjectable } from './PurchasesByItemsTableInjectable'; +import { IPurchasesByItemsReportQuery } from '@/interfaces/PurchasesByItemsSheet'; + +@Service() +export class PurchasesByItemsExport { + @Inject() + private purchasesByItemsTable: PurchasesByItemsTableInjectable; + + /** + * Retrieves the purchases by items sheet in XLSX format. + * @param {number} tenantId + * @param {IPurchasesByItemsReportQuery} query + * @returns {Promise} + */ + public async xlsx( + tenantId: number, + query: IPurchasesByItemsReportQuery + ): Promise { + const table = await this.purchasesByItemsTable.table(tenantId, query); + + const tableSheet = new TableSheet(table.table); + const tableCsv = tableSheet.convertToXLSX(); + + return tableSheet.convertToBuffer(tableCsv, 'xlsx'); + } + + /** + * Retrieves the purchases by items sheet in CSV format. + * @param {number} tenantId + * @param {IPurchasesByItemsReportQuery} query + * @returns {Promise} + */ + public async csv( + tenantId: number, + query: IPurchasesByItemsReportQuery + ): Promise { + const table = await this.purchasesByItemsTable.table(tenantId, query); + + const tableSheet = new TableSheet(table.table); + const tableCsv = tableSheet.convertToCSV(); + + return tableCsv; + } +} diff --git a/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsMeta.ts b/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsMeta.ts new file mode 100644 index 000000000..4bb22c583 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsMeta.ts @@ -0,0 +1,36 @@ +import moment from 'moment'; +import { Inject, Service } from 'typedi'; +import { FinancialSheetMeta } from '../FinancialSheetMeta'; +import { + IPurchasesByItemsReportQuery, + IPurchasesByItemsSheetMeta, +} from '@/interfaces/PurchasesByItemsSheet'; + +@Service() +export class PurchasesByItemsMeta { + @Inject() + private financialSheetMeta: FinancialSheetMeta; + + /** + * Retrieve the purchases by items meta. + * @param {number} tenantId - + * @returns {IBalanceSheetMeta} + */ + public async meta( + tenantId: number, + query: IPurchasesByItemsReportQuery + ): Promise { + const commonMeta = await this.financialSheetMeta.meta(tenantId); + 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 { + ...commonMeta, + sheetName: 'Purchases By Items', + formattedFromDate, + formattedToDate, + formattedDateRange, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsPdf.ts b/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsPdf.ts new file mode 100644 index 000000000..67e3e7a24 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsPdf.ts @@ -0,0 +1,35 @@ +import { Inject, Service } from 'typedi'; +import { TableSheetPdf } from '../TableSheetPdf'; +import { PurchasesByItemsTableInjectable } from './PurchasesByItemsTableInjectable'; +import { IPurchasesByItemsReportQuery } from '@/interfaces/PurchasesByItemsSheet'; +import { HtmlTableCustomCss } from './_types'; + +@Service() +export class PurchasesByItemsPdf { + @Inject() + private purchasesByItemsTable: PurchasesByItemsTableInjectable; + + @Inject() + private 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( + tenantId: number, + query: IPurchasesByItemsReportQuery + ): Promise { + const table = await this.purchasesByItemsTable.table(tenantId, query); + + return this.tableSheetPdf.convertToPdf( + tenantId, + table.table, + table.meta.sheetName, + table.meta.formattedDateRange, + HtmlTableCustomCss + ); + } +} diff --git a/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsService.ts b/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsService.ts index df4aee1ad..a2d6240cb 100644 --- a/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsService.ts +++ b/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsService.ts @@ -1,24 +1,27 @@ -import { Service, Inject } from 'typedi'; import moment from 'moment'; -import { - IInventoryValuationReportQuery, - IInventoryValuationStatement, - IInventoryValuationSheetMeta, -} from '@/interfaces'; +import { Service, Inject } from 'typedi'; import TenancyService from '@/services/Tenancy/TenancyService'; -import PurchasesByItems from './PurchasesByItems'; +import { PurchasesByItems } from './PurchasesByItems'; import { Tenant } from '@/system/models'; +import { + IPurchasesByItemsReportQuery, + IPurchasesByItemsSheet, +} from '@/interfaces/PurchasesByItemsSheet'; +import { PurchasesByItemsMeta } from './PurchasesByItemsMeta'; @Service() -export default class InventoryValuationReportService { +export class PurchasesByItemsService { @Inject() private tenancy: TenancyService; + @Inject() + private purchasesByItemsMeta: PurchasesByItemsMeta; + /** - * Defaults balance sheet filter query. - * @return {IBalanceSheetQuery} + * Defaults purchases by items filter query. + * @return {IPurchasesByItemsReportQuery} */ - get defaultQuery(): IInventoryValuationReportQuery { + private get defaultQuery(): IPurchasesByItemsReportQuery { return { fromDate: moment().startOf('month').format('YYYY-MM-DD'), toDate: moment().format('YYYY-MM-DD'), @@ -35,45 +38,17 @@ export default class InventoryValuationReportService { }; } - /** - * Retrieve the balance sheet meta. - * @param {number} tenantId - - * @returns {IBalanceSheetMeta} - */ - reportMetadata(tenantId: number): IInventoryValuationSheetMeta { - const settings = this.tenancy.settings(tenantId); - - const organizationName = settings.get({ - group: 'organization', - key: 'name', - }); - const baseCurrency = settings.get({ - group: 'organization', - key: 'base_currency', - }); - - return { - organizationName, - baseCurrency, - }; - } - /** * Retrieve balance sheet statement. * ------------- * @param {number} tenantId - * @param {IBalanceSheetQuery} query - * - * @return {IBalanceSheetStatement} + * @param {IPurchasesByItemsReportQuery} query + * @return {Promise} */ public async purchasesByItems( tenantId: number, - query: IInventoryValuationReportQuery - ): Promise<{ - data: IInventoryValuationStatement; - query: IInventoryValuationReportQuery; - meta: IInventoryValuationSheetMeta; - }> { + query: IPurchasesByItemsReportQuery + ): Promise { const { Item, InventoryTransaction } = this.tenancy.models(tenantId); const tenant = await Tenant.query() @@ -106,7 +81,6 @@ export default class InventoryValuationReportService { builder.modify('filterDateRange', filter.fromDate, filter.toDate); } ); - const purchasesByItemsInstance = new PurchasesByItems( filter, inventoryItems, @@ -115,10 +89,13 @@ export default class InventoryValuationReportService { ); const purchasesByItemsData = purchasesByItemsInstance.reportData(); + // Retrieve the purchases by items meta. + const meta = await this.purchasesByItemsMeta.meta(tenantId, query); + return { data: purchasesByItemsData, query: filter, - meta: this.reportMetadata(tenantId), + meta, }; } } diff --git a/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsTable.ts b/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsTable.ts new file mode 100644 index 000000000..a7b6ee735 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsTable.ts @@ -0,0 +1,111 @@ +import * as R from 'ramda'; +import { ITableColumn, ITableColumnAccessor, ITableRow } from '@/interfaces'; +import { ROW_TYPE } from './_types'; +import { tableRowMapper } from '@/utils'; +import { FinancialTable } from '../FinancialTable'; +import { FinancialSheetStructure } from '../FinancialSheetStructure'; +import FinancialSheet from '../FinancialSheet'; +import { + IPurchasesByItemsItem, + IPurchasesByItemsSheetData, + IPurchasesByItemsTotal, +} from '@/interfaces/PurchasesByItemsSheet'; + +export class PurchasesByItemsTable extends R.compose( + FinancialTable, + FinancialSheetStructure +)(FinancialSheet) { + private data: IPurchasesByItemsSheetData; + + /** + * Constructor method. + * @param data + */ + constructor(data) { + super(); + this.data = data; + } + + /** + * Retrieves thge common table accessors. + * @returns {ITableColumnAccessor[]} + */ + private commonTableAccessors(): ITableColumnAccessor[] { + return [ + { key: 'item_name', accessor: 'name' }, + { key: 'quantity_purchases', accessor: 'quantityPurchasedFormatted' }, + { key: 'purchase_amount', accessor: 'purchaseCostFormatted' }, + { key: 'average_cost', accessor: 'averageCostPriceFormatted' }, + ]; + } + + /** + * Retrieves the common table columns. + * @returns {ITableColumn[]} + */ + private commonTableColumns(): ITableColumn[] { + return [ + { label: 'Item name', key: 'item_name' }, + { label: 'Quantity Purchased', key: 'quantity_purchases' }, + { label: 'Purchase Amount', key: 'purchase_amount' }, + { label: 'Average Price', key: 'average_cost' }, + ]; + } + + /** + * Maps the given item node to table row. + * @param {IPurchasesByItemsItem} item + * @returns {ITableRow} + */ + private itemMap = (item: IPurchasesByItemsItem): ITableRow => { + const columns = this.commonTableAccessors(); + const meta = { + rowTypes: [ROW_TYPE.ITEM], + }; + return tableRowMapper(item, columns, meta); + }; + + /** + * Maps the given items nodes to table rows. + * @param {IPurchasesByItemsItem[]} items - Items nodes. + * @returns {ITableRow[]} + */ + private itemsMap = (items: IPurchasesByItemsItem[]): ITableRow[] => { + return R.map(this.itemMap)(items); + }; + + /** + * Maps the given total node to table rows. + * @param {IPurchasesByItemsTotal} total + * @returns {ITableRow} + */ + private totalNodeMap = (total: IPurchasesByItemsTotal): ITableRow => { + const columns = this.commonTableAccessors(); + const meta = { + rowTypes: [ROW_TYPE.TOTAL], + }; + return tableRowMapper(total, columns, meta); + }; + + /** + * Retrieves the table columns. + * @returns {ITableColumn[]} + */ + public tableColumns(): ITableColumn[] { + const columns = this.commonTableColumns(); + return R.compose(this.tableColumnsCellIndexing)(columns); + } + + /** + * Retrieves the table rows. + * @returns {ITableRow[]} + */ + public tableData(): ITableRow[] { + const itemsRows = this.itemsMap(this.data.items); + const totalRow = this.totalNodeMap(this.data.total); + + return R.compose( + R.when(R.always(R.not(R.isEmpty(itemsRows))), R.append(totalRow)) + )(itemsRows) as ITableRow[]; + } +} diff --git a/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsTableInjectable.ts b/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsTableInjectable.ts new file mode 100644 index 000000000..066ac6f72 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsTableInjectable.ts @@ -0,0 +1,38 @@ +import { + IPurchasesByItemsReportQuery, + IPurchasesByItemsTable, +} from '@/interfaces/PurchasesByItemsSheet'; +import { Inject, Service } from 'typedi'; +import { PurchasesByItemsService } from './PurchasesByItemsService'; +import { PurchasesByItemsTable } from './PurchasesByItemsTable'; + +@Service() +export class PurchasesByItemsTableInjectable { + @Inject() + private purchasesByItemsSheet: PurchasesByItemsService; + + /** + * Retrieves the purchases by items table format. + * @param {number} tenantId + * @param {IPurchasesByItemsReportQuery} filter + * @returns {Promise} + */ + public async table( + tenantId: number, + filter: IPurchasesByItemsReportQuery + ): Promise { + const { data, query, meta } = + await this.purchasesByItemsSheet.purchasesByItems(tenantId, filter); + + const table = new PurchasesByItemsTable(data); + + return { + table: { + columns: table.tableColumns(), + rows: table.tableData(), + }, + meta, + query, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/PurchasesByItems/_types.ts b/packages/server/src/services/FinancialStatements/PurchasesByItems/_types.ts new file mode 100644 index 000000000..bb4927958 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/PurchasesByItems/_types.ts @@ -0,0 +1,23 @@ +export enum ROW_TYPE { + TOTAL = 'TOTAL', + ITEM = 'ITEM', +} + +export const HtmlTableCustomCss = ` +table tr.row-type--total td { + border-top: 1px solid #bbb; + border-bottom: 3px double #000; + font-weight: 600; +} +table .column--item_name{ + width: 300px; +} +table .column--quantity_purchases, +table .column--purchase_amount, +table .column--average_cost, +table .cell--quantity_purchases, +table .cell--purchase_amount, +table .cell--average_cost{ + text-align: right; +} +`; diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItems.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItems.ts index 1cb7f0074..9d96febd5 100644 --- a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItems.ts +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItems.ts @@ -1,4 +1,5 @@ import { get, sumBy } from 'lodash'; + import * as R from 'ramda'; import FinancialSheet from '../FinancialSheet'; import { allPassedConditionsPass, transformToMap } from 'utils'; @@ -7,7 +8,7 @@ import { IAccountTransaction, ISalesByItemsItem, ISalesByItemsTotal, - ISalesByItemsSheetStatement, + ISalesByItemsSheetData, IItem, } from '@/interfaces'; @@ -146,7 +147,7 @@ export default class SalesByItemsReport extends FinancialSheet { * @param {IInventoryValuationItem[]} items * @returns {IInventoryValuationTotal} */ - totalSection(items: ISalesByItemsItem[]): ISalesByItemsTotal { + private totalSection(items: ISalesByItemsItem[]): ISalesByItemsTotal { const quantitySold = sumBy(items, (item) => item.quantitySold); const soldCost = sumBy(items, (item) => item.soldCost); @@ -163,12 +164,12 @@ export default class SalesByItemsReport extends FinancialSheet { /** * Retrieve the sheet data. - * @returns {ISalesByItemsSheetStatement} + * @returns {ISalesByItemsSheetData} */ - reportData(): ISalesByItemsSheetStatement { + public reportData(): ISalesByItemsSheetData { const items = this.itemsSection(); const total = this.totalSection(items); - return items.length > 0 ? { items, total } : {}; + return { items, total }; } } diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsApplication.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsApplication.ts new file mode 100644 index 000000000..3ca915e39 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsApplication.ts @@ -0,0 +1,91 @@ +import { Inject, Service } from 'typedi'; + +import { + ISalesByItemsReportQuery, + ISalesByItemsSheet, + ISalesByItemsTable, +} from '@/interfaces'; +import { SalesByItemsReportService } from './SalesByItemsService'; +import { SalesByItemsTableInjectable } from './SalesByItemsTableInjectable'; +import { SalesByItemsExport } from './SalesByItemsExport'; +import { SalesByItemsPdfInjectable } from './SalesByItemsPdfInjectable'; + +@Service() +export class SalesByItemsApplication { + @Inject() + private salesByItemsSheet: SalesByItemsReportService; + + @Inject() + private salesByItemsTable: SalesByItemsTableInjectable; + + @Inject() + private salesByItemsExport: SalesByItemsExport; + + @Inject() + private salesByItemsPdf: SalesByItemsPdfInjectable; + + /** + * Retrieves the sales by items report in json format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} filter + * @returns {Promise} + */ + public sheet( + tenantId: number, + filter: ISalesByItemsReportQuery + ): Promise { + return this.salesByItemsSheet.salesByItems(tenantId, filter); + } + + /** + * Retrieves the sales by items report in table format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} filter + * @returns {Promise} + */ + public table( + tenantId: number, + filter: ISalesByItemsReportQuery + ): Promise { + return this.salesByItemsTable.table(tenantId, filter); + } + + /** + * Retrieves the sales by items report in csv format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} filter + * @returns {Promise} + */ + public csv( + tenantId: number, + filter: ISalesByItemsReportQuery + ): Promise { + return this.salesByItemsExport.csv(tenantId, filter); + } + + /** + * Retrieves the sales by items report in xlsx format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} filter + * @returns {Promise} + */ + public xlsx( + tenantId: number, + filter: ISalesByItemsReportQuery + ): Promise { + return this.salesByItemsExport.xlsx(tenantId, filter); + } + + /** + * Retrieves the sales by items in pdf format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} query + * @returns {Promise} + */ + public pdf( + tenantId: number, + query: ISalesByItemsReportQuery + ): Promise { + return this.salesByItemsPdf.pdf(tenantId, query); + } +} diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsExport.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsExport.ts new file mode 100644 index 000000000..067aab546 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsExport.ts @@ -0,0 +1,43 @@ +import { Inject, Service } from 'typedi'; +import { TableSheet } from '@/lib/Xlsx/TableSheet'; +import { ISalesByItemsReportQuery } from '@/interfaces'; +import { SalesByItemsTableInjectable } from './SalesByItemsTableInjectable'; + +@Service() +export class SalesByItemsExport { + @Inject() + private salesByItemsTable: SalesByItemsTableInjectable; + + /** + * Retrieves the trial balance sheet in XLSX format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} query + * @returns {Promise} + */ + public async xlsx(tenantId: number, query: ISalesByItemsReportQuery) { + const table = await this.salesByItemsTable.table(tenantId, 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 {number} tenantId + * @param {ISalesByItemsReportQuery} query + * @returns {Promise} + */ + public async csv( + tenantId: number, + query: ISalesByItemsReportQuery + ): Promise { + const table = await this.salesByItemsTable.table(tenantId, query); + + const tableSheet = new TableSheet(table.table); + const tableCsv = tableSheet.convertToCSV(); + + return tableCsv; + } +} diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsMeta.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsMeta.ts new file mode 100644 index 000000000..b47b45215 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsMeta.ts @@ -0,0 +1,35 @@ +import moment from 'moment'; +import { Inject, Service } from 'typedi'; +import { FinancialSheetMeta } from '../FinancialSheetMeta'; +import { ISalesByItemsReportQuery, ISalesByItemsSheetMeta } from '@/interfaces'; + +@Service() +export class SalesByItemsMeta { + @Inject() + private financialSheetMeta: FinancialSheetMeta; + + /** + * Retrieve the sales by items meta. + * @param {number} tenantId - + * @returns {IBalanceSheetMeta} + */ + public async meta( + tenantId: number, + query: ISalesByItemsReportQuery + ): Promise { + const commonMeta = await this.financialSheetMeta.meta(tenantId); + 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 By Items'; + + return { + ...commonMeta, + sheetName, + formattedFromDate, + formattedToDate, + formattedDateRange, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsPdfInjectable.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsPdfInjectable.ts new file mode 100644 index 000000000..874154086 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsPdfInjectable.ts @@ -0,0 +1,35 @@ +import { Inject, Service } from 'typedi'; +import { ISalesByItemsReportQuery } from '@/interfaces'; +import { SalesByItemsTableInjectable } from './SalesByItemsTableInjectable'; +import { TableSheetPdf } from '../TableSheetPdf'; +import { HtmlTableCustomCss } from './constants'; + +@Service() +export class SalesByItemsPdfInjectable { + @Inject() + private salesByItemsTable: SalesByItemsTableInjectable; + + @Inject() + private tableSheetPdf: TableSheetPdf; + + /** + * Retrieves the sales by items sheet in pdf format. + * @param {number} tenantId + * @param {number} query + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: ISalesByItemsReportQuery + ): Promise { + const table = await this.salesByItemsTable.table(tenantId, query); + + return this.tableSheetPdf.convertToPdf( + tenantId, + table.table, + table.meta.sheetName, + table.meta.formattedDateRange, + HtmlTableCustomCss + ); + } +} diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts index 6f81e1489..1dc9b4f05 100644 --- a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts @@ -1,27 +1,24 @@ import { Service, Inject } from 'typedi'; import moment from 'moment'; -import { - ISalesByItemsReportQuery, - ISalesByItemsSheetStatement, - ISalesByItemsSheetMeta -} from '@/interfaces'; +import { ISalesByItemsReportQuery, ISalesByItemsSheet } from '@/interfaces'; import TenancyService from '@/services/Tenancy/TenancyService'; import SalesByItems from './SalesByItems'; import { Tenant } from '@/system/models'; +import { SalesByItemsMeta } from './SalesByItemsMeta'; @Service() -export default class SalesByItemsReportService { +export class SalesByItemsReportService { @Inject() - tenancy: TenancyService; + private tenancy: TenancyService; - @Inject('logger') - logger: any; + @Inject() + private salesByItemsMeta: SalesByItemsMeta; /** * Defaults balance sheet filter query. * @return {IBalanceSheetQuery} */ - get defaultQuery(): ISalesByItemsReportQuery { + private get defaultQuery(): ISalesByItemsReportQuery { return { fromDate: moment().startOf('month').format('YYYY-MM-DD'), toDate: moment().format('YYYY-MM-DD'), @@ -38,45 +35,16 @@ export default class SalesByItemsReportService { }; } - /** - * Retrieve the balance sheet meta. - * @param {number} tenantId - - * @returns {IBalanceSheetMeta} - */ - reportMetadata(tenantId: number): ISalesByItemsSheetMeta { - const settings = this.tenancy.settings(tenantId); - - const organizationName = settings.get({ - group: 'organization', - key: 'name', - }); - const baseCurrency = settings.get({ - group: 'organization', - key: 'base_currency', - }); - - return { - organizationName, - baseCurrency, - }; - } - /** * Retrieve balance sheet statement. - * ------------- * @param {number} tenantId * @param {IBalanceSheetQuery} query - * - * @return {IBalanceSheetStatement} + * @return {Promise} */ public async salesByItems( tenantId: number, query: ISalesByItemsReportQuery - ): Promise<{ - data: ISalesByItemsSheetStatement, - query: ISalesByItemsReportQuery, - meta: ISalesByItemsSheetMeta, - }> { + ): Promise { const { Item, InventoryTransaction } = this.tenancy.models(tenantId); const tenant = await Tenant.query() @@ -107,22 +75,24 @@ export default class SalesByItemsReportService { builder.whereIn('itemId', inventoryItemsIds); // Filter the date range of the sheet. - builder.modify('filterDateRange', filter.fromDate, filter.toDate) + builder.modify('filterDateRange', filter.fromDate, filter.toDate); } ); - - const purchasesByItemsInstance = new SalesByItems( + const sheet = new SalesByItems( filter, inventoryItems, inventoryTransactions, - tenant.metadata.baseCurrency, + tenant.metadata.baseCurrency ); - const purchasesByItemsData = purchasesByItemsInstance.reportData(); + const salesByItemsData = sheet.reportData(); + + // Retrieve the sales by items meta. + const meta = await this.salesByItemsMeta.meta(tenantId, query); return { - data: purchasesByItemsData, + data: salesByItemsData, query: filter, - meta: this.reportMetadata(tenantId), + meta, }; } } diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts new file mode 100644 index 000000000..f06c7bd14 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts @@ -0,0 +1,104 @@ +import * as R from 'ramda'; +import { + ISalesByItemsItem, + ISalesByItemsSheetStatement, + ISalesByItemsTotal, + ITableColumn, + ITableRow, +} from '@/interfaces'; +import { tableRowMapper } from '@/utils'; +import FinancialSheet from '../FinancialSheet'; +import { FinancialSheetStructure } from '../FinancialSheetStructure'; +import { FinancialTable } from '../FinancialTable'; +import { ROW_TYPE } from './constants'; + +export class SalesByItemsTable extends R.compose( + FinancialTable, + FinancialSheetStructure +)(FinancialSheet) { + private readonly data: ISalesByItemsSheetStatement; + + /** + * Constructor method. + * @param {ISalesByItemsSheetStatement} data + */ + constructor(data: ISalesByItemsSheetStatement) { + super(); + this.data = data; + } + + /** + * Retrieves the common table accessors. + * @returns {ITableColumn[]} + */ + private commonTableAccessors() { + return [ + { key: 'item_name', accessor: 'name' }, + { key: 'sold_quantity', accessor: 'quantitySoldFormatted' }, + { key: 'sold_amount', accessor: 'soldCostFormatted' }, + { key: 'average_price', accessor: 'averageSellPriceFormatted' }, + ]; + } + + /** + * Maps the given item node to table row. + * @param {ISalesByItemsItem} item + * @returns {ITableRow} + */ + private itemMap = (item: ISalesByItemsItem): ITableRow => { + const columns = this.commonTableAccessors(); + const meta = { + rowTypes: [ROW_TYPE.ITEM], + }; + return tableRowMapper(item, columns, meta); + }; + + /** + * Maps the given items nodes to table rows. + * @param {ISalesByItemsItem[]} items + * @returns {ITableRow[]} + */ + private itemsMap = (items: ISalesByItemsItem[]): ITableRow[] => { + return R.map(this.itemMap, items); + }; + + /** + * Maps the given total node to table row. + * @param {ISalesByItemsTotal} total + * @returns {ITableRow[]} + */ + private totalMap = (total: ISalesByItemsTotal) => { + const columns = this.commonTableAccessors(); + const meta = { + rowTypes: [ROW_TYPE.TOTAL], + }; + return tableRowMapper(total, columns, meta); + }; + + /** + * Retrieves the table rows. + * @returns {ITableRow[]} + */ + public tableData(): ITableRow[] { + const itemsRows = this.itemsMap(this.data.items); + const totalRow = this.totalMap(this.data.total); + + return R.compose( + R.when(R.always(R.not(R.isEmpty(itemsRows))), R.append(totalRow)) + )([...itemsRows]) as ITableRow[]; + } + + /** + * Retrieves the table columns. + * @returns {ITableColumn[]} + */ + public tableColumns(): ITableColumn[] { + const columns = [ + { key: 'item_name', label: 'Item name' }, + { key: 'sold_quantity', label: 'Sold quantity' }, + { key: 'sold_amount', label: 'Sold amount' }, + { key: 'average_price', label: 'Average price' }, + ]; + return R.compose(this.tableColumnsCellIndexing)(columns); + } +} diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTableInjectable.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTableInjectable.ts new file mode 100644 index 000000000..c947cfa86 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTableInjectable.ts @@ -0,0 +1,33 @@ +import { Inject, Service } from 'typedi'; +import { ISalesByItemsReportQuery } from '@/interfaces'; +import { SalesByItemsReportService } from './SalesByItemsService'; +import { SalesByItemsTable } from './SalesByItemsTable'; + +@Service() +export class SalesByItemsTableInjectable { + @Inject() + private salesByItemSheet: SalesByItemsReportService; + + /** + * Retrieves the sales by items report in table format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} filter + * @returns {Promise} + */ + public async table(tenantId: number, filter: ISalesByItemsReportQuery) { + const { data, query, meta } = await this.salesByItemSheet.salesByItems( + tenantId, + filter + ); + const table = new SalesByItemsTable(data); + + return { + table: { + columns: table.tableColumns(), + rows: table.tableData(), + }, + meta, + query, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/constants.ts b/packages/server/src/services/FinancialStatements/SalesByItems/constants.ts new file mode 100644 index 000000000..761e37ec5 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesByItems/constants.ts @@ -0,0 +1,23 @@ +export enum ROW_TYPE { + ITEM = 'ITEM', + TOTAL = 'TOTAL', +} + +export const HtmlTableCustomCss = ` +table tr.row-type--total td { + border-top: 1px solid #bbb; + border-bottom: 3px double #000; + font-weight: 600; +} +table .column--item_name{ + width: 300px; +} +table .column--average_price, +table .column--sold_quantity, +table .column--sold_amount, +table .cell--average_price, +table .cell--sold_quantity, +table .cell--sold_amount{ + text-align: right; +} +`; diff --git a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryApplication.ts b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryApplication.ts index f0e5a5248..f63123687 100644 --- a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryApplication.ts +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryApplication.ts @@ -3,6 +3,7 @@ import { SalesTaxLiabilitySummaryQuery } from '@/interfaces/SalesTaxLiabilitySum import { SalesTaxLiabilitySummaryTableInjectable } from './SalesTaxLiabilitySummaryTableInjectable'; import { SalesTaxLiabilitySummaryExportInjectable } from './SalesTaxLiabilitySummaryExportInjectable'; import { SalesTaxLiabilitySummaryService } from './SalesTaxLiabilitySummaryService'; +import { SalesTaxLiabiltiySummaryPdf } from './SalesTaxLiabiltiySummaryPdf'; @Service() export class SalesTaxLiabilitySummaryApplication { @@ -15,6 +16,9 @@ export class SalesTaxLiabilitySummaryApplication { @Inject() private salesTaxLiabilityTable: SalesTaxLiabilitySummaryTableInjectable; + @Inject() + private salesTaxLiabiltiyPdf: SalesTaxLiabiltiySummaryPdf; + /** * Retrieves the sales tax liability summary in json format. * @param {number} tenantId @@ -60,4 +64,17 @@ export class SalesTaxLiabilitySummaryApplication { ): Promise { return this.salesTaxLiabilityExport.csv(tenantId, query); } + + /** + * Retrieves the sales tax liability summary in PDF format. + * @param {number} tenantId + * @param {SalesTaxLiabilitySummaryQuery} query + * @returns {Promise} + */ + public pdf( + tenantId: number, + query: SalesTaxLiabilitySummaryQuery + ): Promise { + return this.salesTaxLiabiltiyPdf.pdf(tenantId, query); + } } diff --git a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryMeta.ts b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryMeta.ts new file mode 100644 index 000000000..c18a8f55d --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryMeta.ts @@ -0,0 +1,32 @@ +import { Inject, Service } from 'typedi'; +import moment from 'moment'; +import { SalesTaxLiabilitySummaryQuery } from '@/interfaces/SalesTaxLiabilitySummary'; +import { FinancialSheetMeta } from '../FinancialSheetMeta'; + +@Service() +export class SalesTaxLiabilitySummaryMeta { + @Inject() + private financialSheetMeta: FinancialSheetMeta; + + /** + * Retrieves the report meta. + * @param {number} tenantId + * @param {SalesTaxLiabilitySummaryQuery} filter + */ + public async meta(tenantId: number, query: SalesTaxLiabilitySummaryQuery) { + const commonMeta = await this.financialSheetMeta.meta(tenantId); + 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/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts index a1ec7a771..585e9e4d9 100644 --- a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts @@ -1,11 +1,8 @@ import { Inject, Service } from 'typedi'; import { SalesTaxLiabilitySummaryRepository } from './SalesTaxLiabilitySummaryRepository'; -import { - SalesTaxLiabilitySummaryMeta, - SalesTaxLiabilitySummaryQuery, -} from '@/interfaces/SalesTaxLiabilitySummary'; +import { SalesTaxLiabilitySummaryQuery } from '@/interfaces/SalesTaxLiabilitySummary'; import { SalesTaxLiabilitySummary } from './SalesTaxLiabilitySummary'; -import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { SalesTaxLiabilitySummaryMeta } from './SalesTaxLiabilitySummaryMeta'; @Service() export class SalesTaxLiabilitySummaryService { @@ -13,7 +10,7 @@ export class SalesTaxLiabilitySummaryService { private repostiory: SalesTaxLiabilitySummaryRepository; @Inject() - private tenancy: HasTenancyService; + private salesTaxLiabilityMeta: SalesTaxLiabilitySummaryMeta; /** * Retrieve sales tax liability summary. @@ -39,33 +36,12 @@ export class SalesTaxLiabilitySummaryService { payableByRateId, salesByRateId ); + const meta = await this.salesTaxLiabilityMeta.meta(tenantId, query); + return { data: taxLiabilitySummary.reportData(), query, - meta: this.reportMetadata(tenantId), - }; - } - - /** - * Retrieve the report meta. - * @param {number} tenantId - - * @returns {IBalanceSheetMeta} - */ - private reportMetadata(tenantId: number): SalesTaxLiabilitySummaryMeta { - const settings = this.tenancy.settings(tenantId); - - const organizationName = settings.get({ - group: 'organization', - key: 'name', - }); - const baseCurrency = settings.get({ - group: 'organization', - key: 'base_currency', - }); - - return { - organizationName, - baseCurrency, + meta, }; } } diff --git a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabiltiySummaryPdf.ts b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabiltiySummaryPdf.ts new file mode 100644 index 000000000..742241842 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabiltiySummaryPdf.ts @@ -0,0 +1,36 @@ +import { Inject, Service } from 'typedi'; +import { TableSheetPdf } from '../TableSheetPdf'; +import { SalesTaxLiabilitySummaryTableInjectable } from './SalesTaxLiabilitySummaryTableInjectable'; +import { ISalesByItemsReportQuery } from '@/interfaces'; +import { SalesTaxLiabilitySummaryQuery } from '@/interfaces/SalesTaxLiabilitySummary'; + +@Service() +export class SalesTaxLiabiltiySummaryPdf { + @Inject() + private salesTaxLiabiltiySummaryTable: SalesTaxLiabilitySummaryTableInjectable; + + @Inject() + private tableSheetPdf: TableSheetPdf; + + /** + * Converts the given sales tax liability summary table to pdf. + * @param {number} tenantId - Tenant ID. + * @param {ISalesByItemsReportQuery} query - Balance sheet query. + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: SalesTaxLiabilitySummaryQuery + ): Promise { + const table = await this.salesTaxLiabiltiySummaryTable.table( + tenantId, + query + ); + return this.tableSheetPdf.convertToPdf( + tenantId, + table.table, + table.meta.sheetName, + table.meta.formattedDateRange + ); + } +} diff --git a/packages/server/src/services/FinancialStatements/TableSheetPdf.ts b/packages/server/src/services/FinancialStatements/TableSheetPdf.ts new file mode 100644 index 000000000..280adf187 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/TableSheetPdf.ts @@ -0,0 +1,79 @@ +import { Inject, Service } from 'typedi'; +import * as R from 'ramda'; +import { ITableColumn, ITableData, ITableRow } from '@/interfaces'; +import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; +import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; +import { FinancialTableStructure } from './FinancialTableStructure'; +import { tableClassNames } from './utils'; + +@Service() +export class TableSheetPdf { + @Inject() + private templateInjectable: TemplateInjectable; + + @Inject() + private chromiumlyTenancy: ChromiumlyTenancy; + + /** + * Converts the table data into a PDF format. + * @param {number} tenantId - The unique identifier for the tenant. + * @param {ITableData} table - The table data to be converted. + * @param {string} sheetName - The name of the sheet. + * @param {string} sheetDate - The date of the sheet. + * @returns A promise that resolves with the PDF conversion result. + */ + public async convertToPdf( + tenantId: number, + table: ITableData, + sheetName: string, + sheetDate: string, + customCSS?: string + ): Promise { + // Prepare columns and rows for PDF conversion + const columns = this.tablePdfColumns(table.columns); + const rows = this.tablePdfRows(table.rows); + + const landscape = columns.length > 4; + + // Generate HTML content from the template + const htmlContent = await this.templateInjectable.render( + tenantId, + 'modules/financial-sheet', + { + table: { rows, columns }, + sheetName, + sheetDate, + customCSS, + } + ); + // Convert the HTML content to PDF + return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent, { + margins: { top: 0, bottom: 0, left: 0, right: 0 }, + landscape, + }); + } + + /** + * Converts the table columns to pdf columns. + * @param {ITableColumn[]} columns + * @returns {ITableColumn[]} + */ + private tablePdfColumns = (columns: ITableColumn[]): ITableColumn[] => { + return columns; + }; + + /** + * Converts the table rows to pdf rows. + * @param {ITableRow[]} rows - + * @returns {ITableRow[]} + */ + private tablePdfRows = (rows: ITableRow[]): ITableRow[] => { + const curriedFlatNestedTree = R.curry( + FinancialTableStructure.flatNestedTree + ); + const flatNestedTree = curriedFlatNestedTree(R.__, { + nestedPrefix: '', + }); + return R.compose(tableClassNames, flatNestedTree)(rows); + }; +} diff --git a/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersApplication.ts b/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersApplication.ts index b729c219a..c3e4feb7e 100644 --- a/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersApplication.ts +++ b/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersApplication.ts @@ -6,6 +6,7 @@ import { import { TransactionsByCustomersTableInjectable } from './TransactionsByCustomersTableInjectable'; import { TransactionsByCustomersExportInjectable } from './TransactionsByCustomersExportInjectable'; import { TransactionsByCustomersSheet } from './TransactionsByCustomersService'; +import { TransactionsByCustomersPdf } from './TransactionsByCustomersPdf'; @Service() export class TransactionsByCustomerApplication { @@ -18,6 +19,9 @@ export class TransactionsByCustomerApplication { @Inject() private transactionsByCustomersSheet: TransactionsByCustomersSheet; + @Inject() + private transactionsByCustomersPdf: TransactionsByCustomersPdf; + /** * Retrieves the transactions by customers sheet in json format. * @param {number} tenantId @@ -69,4 +73,17 @@ export class TransactionsByCustomerApplication { ): Promise { return this.transactionsByCustomersExport.xlsx(tenantId, query); } + + /** + * Retrieves the transactions by vendors sheet in PDF format. + * @param {number} tenantId + * @param {ITransactionsByCustomersFilter} query + * @returns {Promise} + */ + public pdf( + tenantId: number, + query: ITransactionsByCustomersFilter + ): Promise { + return this.transactionsByCustomersPdf.pdf(tenantId, query); + } } diff --git a/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersMeta.ts b/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersMeta.ts new file mode 100644 index 000000000..710bc0bc4 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersMeta.ts @@ -0,0 +1,36 @@ +import moment from 'moment'; +import { Inject, Service } from 'typedi'; +import { FinancialSheetMeta } from '../FinancialSheetMeta'; +import { + ITransactionsByCustomersFilter, + ITransactionsByCustomersMeta, +} from '@/interfaces'; + +@Service() +export class TransactionsByCustomersMeta { + @Inject() + private financialSheetMeta: FinancialSheetMeta; + + /** + * Retrieves the transactions by customers meta. + * @param {number} tenantId - + * @returns {IBalanceSheetMeta} + */ + public async meta( + tenantId: number, + query: ITransactionsByCustomersFilter + ): Promise { + const commonMeta = await this.financialSheetMeta.meta(tenantId); + 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 { + ...commonMeta, + sheetName: 'Transactions By Customers', + formattedFromDate, + formattedToDate, + formattedDateRange, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersPdf.ts b/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersPdf.ts new file mode 100644 index 000000000..122cbbaf2 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersPdf.ts @@ -0,0 +1,34 @@ +import { ITransactionsByCustomersFilter } from '@/interfaces'; +import { Inject } from 'typedi'; +import { TableSheetPdf } from '../TableSheetPdf'; +import { TransactionsByCustomersTableInjectable } from './TransactionsByCustomersTableInjectable'; + +export class TransactionsByCustomersPdf { + @Inject() + private transactionsByCustomersTable: TransactionsByCustomersTableInjectable; + + @Inject() + private tableSheetPdf: TableSheetPdf; + + /** + * Retrieves the transactions by customers in PDF format. + * @param {number} tenantId - Tenant ID. + * @param {ITransactionsByCustomersFilter} query - Balance sheet query. + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: ITransactionsByCustomersFilter + ): Promise { + const table = await this.transactionsByCustomersTable.table( + tenantId, + query + ); + return this.tableSheetPdf.convertToPdf( + tenantId, + table.table, + table.meta.sheetName, + table.meta.formattedDateRange + ); + } +} diff --git a/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersService.ts b/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersService.ts index 3fbf8cf06..a97a13afe 100644 --- a/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersService.ts +++ b/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersService.ts @@ -12,6 +12,7 @@ import TransactionsByCustomers from './TransactionsByCustomers'; import Ledger from '@/services/Accounting/Ledger'; import TransactionsByCustomersRepository from './TransactionsByCustomersRepository'; import { Tenant } from '@/system/models'; +import { TransactionsByCustomersMeta } from './TransactionsByCustomersMeta'; export class TransactionsByCustomersSheet implements ITransactionsByCustomersService @@ -22,6 +23,9 @@ export class TransactionsByCustomersSheet @Inject() private reportRepository: TransactionsByCustomersRepository; + @Inject() + private transactionsByCustomersMeta: TransactionsByCustomersMeta; + /** * Defaults balance sheet filter query. * @return {ICustomerBalanceSummaryQuery} @@ -160,9 +164,12 @@ export class TransactionsByCustomersSheet i18n ); + const meta = await this.transactionsByCustomersMeta.meta(tenantId, filter); + return { data: reportInstance.reportData(), query: filter, + meta, }; } } diff --git a/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersTable.ts b/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersTable.ts index 7016cf853..2f38b9cde 100644 --- a/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersTable.ts +++ b/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersTable.ts @@ -1,6 +1,10 @@ import * as R from 'ramda'; import { tableRowMapper } from 'utils'; -import { ITransactionsByCustomersCustomer, ITableRow, ITableColumn } from '@/interfaces'; +import { + ITransactionsByCustomersCustomer, + ITableRow, + ITableColumn, +} from '@/interfaces'; import TransactionsByContactsTableRows from '../TransactionsByContact/TransactionsByContactTableRows'; enum ROW_TYPE { @@ -78,6 +82,14 @@ export class TransactionsByCustomersTable extends TransactionsByContactsTableRow * @returns {ITableColumn[]} */ public tableColumns = (): ITableColumn[] => { - return []; - } + return [ + { key: 'customer_name', label: 'Customer name' }, + { key: 'account_name', label: 'Account Name' }, + { key: 'ref_type', label: 'Reference Type' }, + { key: 'transaction_type', label: 'Transaction Type' }, + { key: 'credit', label: 'Credit' }, + { key: 'debit', label: 'Debit' }, + { key: 'running_balance', label: 'Running Balance' }, + ]; + }; } diff --git a/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersTableInjectable.ts b/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersTableInjectable.ts index fb1c61311..2f2aa0277 100644 --- a/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersTableInjectable.ts +++ b/packages/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersTableInjectable.ts @@ -39,6 +39,7 @@ export class TransactionsByCustomersTableInjectable { columns: table.tableColumns(), }, query: customersTransactions.query, + meta: customersTransactions.meta }; } } diff --git a/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorApplication.ts b/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorApplication.ts index d8d424a30..54a4c7ed4 100644 --- a/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorApplication.ts +++ b/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorApplication.ts @@ -7,6 +7,7 @@ import { import { TransactionsByVendorExportInjectable } from './TransactionsByVendorExportInjectable'; import { TransactionsByVendorTableInjectable } from './TransactionsByVendorTableInjectable'; import { TransactionsByVendorsInjectable } from './TransactionsByVendorInjectable'; +import { TransactionsByVendorsPdf } from './TransactionsByVendorPdf'; @Service() export class TransactionsByVendorApplication { @@ -19,6 +20,9 @@ export class TransactionsByVendorApplication { @Inject() private transactionsByVendorSheet: TransactionsByVendorsInjectable; + @Inject() + private transactionsByVendorPdf: TransactionsByVendorsPdf; + /** * Retrieves the transactions by vendor in sheet format. * @param {number} tenantId @@ -65,6 +69,7 @@ export class TransactionsByVendorApplication { * Retrieves the transactions by vendor in XLSX format. * @param {number} tenantId * @param {ITransactionsByVendorsFilter} query + * @returns {Promise} */ public xlsx( tenantId: number, @@ -72,4 +77,14 @@ export class TransactionsByVendorApplication { ): Promise { return this.transactionsByVendorExport.xlsx(tenantId, query); } + + /** + * Retrieves the transactions by vendor in PDF format. + * @param {number} tenantId + * @param {ITransactionsByVendorsFilter} query + * @returns {Promise} + */ + public pdf(tenantId: number, query: ITransactionsByVendorsFilter) { + return this.transactionsByVendorPdf.pdf(tenantId, query); + } } diff --git a/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorInjectable.ts b/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorInjectable.ts index a24041929..69b424957 100644 --- a/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorInjectable.ts +++ b/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorInjectable.ts @@ -12,6 +12,7 @@ import TransactionsByVendor from './TransactionsByVendor'; import Ledger from '@/services/Accounting/Ledger'; import TransactionsByVendorRepository from './TransactionsByVendorRepository'; import { Tenant } from '@/system/models'; +import { TransactionsByVendorMeta } from './TransactionsByVendorMeta'; export class TransactionsByVendorsInjectable implements ITransactionsByVendorsService @@ -22,6 +23,9 @@ export class TransactionsByVendorsInjectable @Inject() private reportRepository: TransactionsByVendorRepository; + @Inject() + private transactionsByVendorMeta: TransactionsByVendorMeta; + /** * Defaults balance sheet filter query. * @return {IVendorBalanceSummaryQuery} @@ -165,9 +169,12 @@ export class TransactionsByVendorsInjectable tenant.metadata.baseCurrency, i18n ); + const meta = await this.transactionsByVendorMeta.meta(tenantId, filter); + return { data: reportInstance.reportData(), query: filter, + meta, }; } } diff --git a/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorMeta.ts b/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorMeta.ts new file mode 100644 index 000000000..42295cc22 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorMeta.ts @@ -0,0 +1,38 @@ +import moment from 'moment'; +import { Inject, Service } from 'typedi'; +import { FinancialSheetMeta } from '../FinancialSheetMeta'; +import { + ITransactionsByVendorMeta, + ITransactionsByVendorsFilter, +} from '@/interfaces'; + +@Service() +export class TransactionsByVendorMeta { + @Inject() + private financialSheetMeta: FinancialSheetMeta; + + /** + * Retrieves the transactions by vendor meta. + * @param {number} tenantId - + * @returns {Promise} + */ + public async meta( + tenantId: number, + query: ITransactionsByVendorsFilter + ): Promise { + const commonMeta = await this.financialSheetMeta.meta(tenantId); + 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 = 'Transactions By Vendor'; + + return { + ...commonMeta, + sheetName, + formattedFromDate, + formattedToDate, + formattedDateRange, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorPdf.ts b/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorPdf.ts new file mode 100644 index 000000000..d133a9ebc --- /dev/null +++ b/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorPdf.ts @@ -0,0 +1,35 @@ +import { Inject, Service } from 'typedi'; +import { ITransactionsByVendorsFilter } from '@/interfaces'; +import { TableSheetPdf } from '../TableSheetPdf'; +import { TransactionsByVendorTableInjectable } from './TransactionsByVendorTableInjectable'; +import { HtmlTableCustomCss } from './constants'; + +@Service() +export class TransactionsByVendorsPdf { + @Inject() + private transactionsByVendorTable: TransactionsByVendorTableInjectable; + + @Inject() + private tableSheetPdf: TableSheetPdf; + + /** + * Converts the given balance sheet table to pdf. + * @param {number} tenantId - Tenant ID. + * @param {IBalanceSheetQuery} query - Balance sheet query. + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: ITransactionsByVendorsFilter + ): Promise { + const table = await this.transactionsByVendorTable.table(tenantId, query); + + return this.tableSheetPdf.convertToPdf( + tenantId, + table.table, + table.meta.sheetName, + table.meta.formattedDateRange, + HtmlTableCustomCss + ); + } +} diff --git a/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorTable.ts b/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorTable.ts index 14ad3c1b4..2f8bf2498 100644 --- a/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorTable.ts +++ b/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorTable.ts @@ -43,7 +43,6 @@ export class TransactionsByVendorsTable extends TransactionsByContactsTableRows accessor: 'closingBalance.formattedAmount', }, ]; - return { ...tableRowMapper(vendor, columns, { rowTypes: [ROW_TYPE.VENDOR] }), children: R.pipe( @@ -82,6 +81,14 @@ export class TransactionsByVendorsTable extends TransactionsByContactsTableRows * @returns {ITableColumn[]} */ public tableColumns = (): ITableColumn[] => { - return []; + return [ + { key: 'vendor_name', label: 'Vendor name' }, + { key: 'account_name', label: 'Account Name' }, + { key: 'ref_type', label: 'Reference Type' }, + { key: 'transaction_type', label: 'Transaction Type' }, + { key: 'credit', label: 'Credit' }, + { key: 'debit', label: 'Debit' }, + { key: 'running_balance', label: 'Running Balance' }, + ]; }; } diff --git a/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorTableInjectable.ts b/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorTableInjectable.ts index 5b42b88e7..5c5243059 100644 --- a/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorTableInjectable.ts +++ b/packages/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorTableInjectable.ts @@ -39,6 +39,7 @@ export class TransactionsByVendorTableInjectable { columns: table.tableColumns(), }, query, + meta: sheet.meta, }; } } diff --git a/packages/server/src/services/FinancialStatements/TransactionsByVendor/constants.ts b/packages/server/src/services/FinancialStatements/TransactionsByVendor/constants.ts new file mode 100644 index 000000000..d35a2f105 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/TransactionsByVendor/constants.ts @@ -0,0 +1,23 @@ +export const HtmlTableCustomCss = ` +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, +table .column--running_balance, +table .cell--running_balance{ + text-align: right; +} +table tr.row-type--closing-balance td, +table tr.row-type--opening-balance td { + font-weight: 600; +} +table tr.row-type--vendor:not(:first-child) td { + border-top: 1px solid #ddd; +} +`; diff --git a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceExportInjectable.ts b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceExportInjectable.ts index a515f1beb..62b847f7c 100644 --- a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceExportInjectable.ts +++ b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceExportInjectable.ts @@ -1,13 +1,17 @@ +import { Inject, Service } from 'typedi'; import { TableSheet } from '@/lib/Xlsx/TableSheet'; import { ITrialBalanceSheetQuery } from '@/interfaces'; -import { Inject, Service } from 'typedi'; import { TrialBalanceSheetTableInjectable } from './TrialBalanceSheetTableInjectable'; +import { TrialBalanceSheetPdfInjectable } from './TrialBalanceSheetPdfInjectsable'; @Service() export class TrialBalanceExportInjectable { @Inject() private trialBalanceSheetTable: TrialBalanceSheetTableInjectable; + @Inject() + private trialBalanceSheetPdf: TrialBalanceSheetPdfInjectable; + /** * Retrieves the trial balance sheet in XLSX format. * @param {number} tenantId @@ -40,4 +44,17 @@ export class TrialBalanceExportInjectable { return tableCsv; } + + /** + * Retrieves the trial balance sheet in PDF format. + * @param {number} tenantId + * @param {ITrialBalanceSheetQuery} query + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: ITrialBalanceSheetQuery + ): Promise { + return this.trialBalanceSheetPdf.pdf(tenantId, query); + } } diff --git a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheet.ts b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheet.ts index 38ed3a944..8554268e3 100644 --- a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheet.ts +++ b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheet.ts @@ -252,10 +252,6 @@ export default class TrialBalanceSheet extends FinancialSheet { * @return {ITrialBalanceSheetData} */ public reportData(): ITrialBalanceSheetData { - // Don't return noting if the journal has no transactions. - if (this.repository.totalAccountsLedger.isEmpty()) { - return null; - } // Retrieve accounts nodes. const accounts = this.accountsSection(this.repository.accounts); diff --git a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetApplication.ts b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetApplication.ts index a771c8f15..20c485ecc 100644 --- a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetApplication.ts +++ b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetApplication.ts @@ -57,4 +57,14 @@ export class TrialBalanceSheetApplication { public async xlsx(tenantId: number, query: ITrialBalanceSheetQuery) { return this.exportable.xlsx(tenantId, query); } + + /** + * Retrieve the trial balance sheet in pdf format. + * @param {number} tenantId + * @param {ITrialBalanceSheetQuery} query + * @returns {Promise} + */ + public async pdf(tenantId: number, query: ITrialBalanceSheetQuery) { + return this.exportable.pdf(tenantId, query); + } } diff --git a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetInjectable.ts b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetInjectable.ts index bc880ec99..df498241b 100644 --- a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetInjectable.ts +++ b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetInjectable.ts @@ -1,28 +1,20 @@ import { Service, Inject } from 'typedi'; import moment from 'moment'; import TenancyService from '@/services/Tenancy/TenancyService'; -import { - ITrialBalanceSheetMeta, - ITrialBalanceSheetQuery, - ITrialBalanceStatement, -} from '@/interfaces'; +import { ITrialBalanceSheetQuery, ITrialBalanceStatement } from '@/interfaces'; import TrialBalanceSheet from './TrialBalanceSheet'; import FinancialSheet from '../FinancialSheet'; -import InventoryService from '@/services/Inventory/Inventory'; -import { parseBoolean } from 'utils'; import { Tenant } from '@/system/models'; import { TrialBalanceSheetRepository } from './TrialBalanceSheetRepository'; +import { TrialBalanceSheetMeta } from './TrialBalanceSheetMeta'; @Service() export default class TrialBalanceSheetService extends FinancialSheet { @Inject() - tenancy: TenancyService; + private tenancy: TenancyService; @Inject() - inventoryService: InventoryService; - - @Inject('logger') - logger: any; + private trialBalanceSheetMetaService: TrialBalanceSheetMeta; /** * Defaults trial balance sheet filter query. @@ -47,32 +39,6 @@ export default class TrialBalanceSheetService extends FinancialSheet { }; } - /** - * Retrieve the trial balance sheet meta. - * @param {number} tenantId - Tenant id. - * @returns {ITrialBalanceSheetMeta} - */ - private reportMetadata(tenantId: number): ITrialBalanceSheetMeta { - const settings = this.tenancy.settings(tenantId); - - const isCostComputeRunning = - this.inventoryService.isItemsCostComputeRunning(tenantId); - const organizationName = settings.get({ - group: 'organization', - key: 'name', - }); - const baseCurrency = settings.get({ - group: 'organization', - key: 'base_currency', - }); - - return { - isCostComputeRunning: parseBoolean(isCostComputeRunning, false), - organizationName, - baseCurrency, - }; - } - /** * Retrieve trial balance sheet statement. * @param {number} tenantId @@ -99,6 +65,7 @@ export default class TrialBalanceSheetService extends FinancialSheet { repos, filter ); + // Loads the resources. await trialBalanceSheetRepos.asyncInitialize(); // Trial balance report instance. @@ -111,10 +78,13 @@ export default class TrialBalanceSheetService extends FinancialSheet { // Trial balance sheet data. const trialBalanceSheetData = trialBalanceInstance.reportData(); + // Trial balance sheet meta. + const meta = await this.trialBalanceSheetMetaService.meta(tenantId, filter); + return { data: trialBalanceSheetData, query: filter, - meta: this.reportMetadata(tenantId), + meta, }; } } diff --git a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetMeta.ts b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetMeta.ts new file mode 100644 index 000000000..7561e2b66 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetMeta.ts @@ -0,0 +1,37 @@ +import { Inject, Service } from 'typedi'; +import moment from 'moment'; +import { ITrialBalanceSheetMeta, ITrialBalanceSheetQuery } from '@/interfaces'; +import { FinancialSheetMeta } from '../FinancialSheetMeta'; + +@Service() +export class TrialBalanceSheetMeta { + @Inject() + private financialSheetMeta: FinancialSheetMeta; + + /** + * Retrieves the trial balance sheet meta. + * @param {number} tenantId + * @param {ITrialBalanceSheetQuery} query + * @returns {Promise} + */ + public async meta( + tenantId: number, + query: ITrialBalanceSheetQuery + ): Promise { + const commonMeta = await this.financialSheetMeta.meta(tenantId); + + const formattedFromDate = moment(query.fromDate).format('YYYY/MM/DD'); + const formattedToDate = moment(query.toDate).format('YYYY/MM/DD'); + const formattedDateRange = `From ${formattedFromDate} to ${formattedToDate}`; + + const sheetName = 'Trial Balance Sheet'; + + return { + ...commonMeta, + sheetName, + formattedFromDate, + formattedToDate, + formattedDateRange, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetPdfInjectsable.ts b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetPdfInjectsable.ts new file mode 100644 index 000000000..b7b53d437 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetPdfInjectsable.ts @@ -0,0 +1,35 @@ +import { Inject, Service } from 'typedi'; +import { ITrialBalanceSheetQuery } from '@/interfaces'; +import { TableSheetPdf } from '../TableSheetPdf'; +import { TrialBalanceSheetTableInjectable } from './TrialBalanceSheetTableInjectable'; +import { HtmlTableCustomCss } from './_constants'; + +@Service() +export class TrialBalanceSheetPdfInjectable { + @Inject() + private trialBalanceSheetTable: TrialBalanceSheetTableInjectable; + + @Inject() + private tableSheetPdf: TableSheetPdf; + + /** + * Converts the given trial balance sheet table to pdf. + * @param {number} tenantId - Tenant ID. + * @param {ITrialBalanceSheetQuery} query - Trial balance sheet query. + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: ITrialBalanceSheetQuery + ): Promise { + const table = await this.trialBalanceSheetTable.table(tenantId, query); + + return this.tableSheetPdf.convertToPdf( + tenantId, + table.table, + table.meta.sheetName, + table.meta.formattedDateRange, + HtmlTableCustomCss + ); + } +} diff --git a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetTable.ts b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetTable.ts index 1cbb2e7e6..0d4df537e 100644 --- a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetTable.ts +++ b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetTable.ts @@ -2,7 +2,6 @@ import * as R from 'ramda'; import FinancialSheet from '../FinancialSheet'; import { FinancialTable } from '../FinancialTable'; import { - IBalanceSheetStatementData, ITableColumn, ITableColumnAccessor, ITableRow, @@ -20,12 +19,13 @@ export class TrialBalanceSheetTable extends R.compose( FinancialSheetStructure )(FinancialSheet) { /** + * Trial balance sheet data. * @param {ITrialBalanceSheetData} */ public data: ITrialBalanceSheetData; /** - * Balance sheet query. + * Trial balance sheet query. * @param {ITrialBalanceSheetQuery} */ public query: ITrialBalanceSheetQuery; @@ -46,7 +46,7 @@ export class TrialBalanceSheetTable extends R.compose( this.query = query; this.i18n = i18n; } - + /** * Retrieve the common columns for all report nodes. * @param {ITableColumnAccessor[]} @@ -123,7 +123,7 @@ export class TrialBalanceSheetTable extends R.compose( */ public tableRows = (): ITableRow[] => { return R.compose( - R.append(this.totalTableRow()), + R.unless(R.isEmpty, R.append(this.totalTableRow())), R.concat(this.accountsTableRows()) )([]); }; @@ -136,7 +136,7 @@ export class TrialBalanceSheetTable extends R.compose( return R.compose( this.tableColumnsCellIndexing, R.concat([ - { key: 'account_name', label: 'Account' }, + { key: 'account', label: 'Account' }, { key: 'debit', label: 'Debit' }, { key: 'credit', label: 'Credit' }, { key: 'total', label: 'Total' }, diff --git a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/_constants.ts b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/_constants.ts index 91e8c595f..7a52e631b 100644 --- a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/_constants.ts +++ b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/_constants.ts @@ -1,5 +1,25 @@ - export enum IROW_TYPE { ACCOUNT = 'ACCOUNT', TOTAL = 'TOTAL', -} \ No newline at end of file +} + +export const HtmlTableCustomCss = ` +table tr.row-type--total td{ + border-top: 1px solid #bbb; + font-weight: 600; + border-bottom: 3px double #000; +} + +table .column--account { + width: 400px; +} + +table .column--debit, +table .column--credit, +table .column--total, +table .cell--debit, +table .cell--credit, +table .cell--total{ + text-align: right; +} +`; diff --git a/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummary.ts b/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummary.ts index 2e5e09aa4..6d04461b6 100644 --- a/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummary.ts +++ b/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummary.ts @@ -41,7 +41,7 @@ export class VendorBalanceSummaryReport extends ContactBalanceSummaryReport { /** * Customer section mapper. - * @param {IVendor} vendor + * @param {IVendor} vendor * @returns {IVendorBalanceSummaryVendor} */ private vendorMapper = (vendor: IVendor): IVendorBalanceSummaryVendor => { @@ -58,7 +58,7 @@ export class VendorBalanceSummaryReport extends ContactBalanceSummaryReport { /** * Mappes the vendor model object to vendor balance summary section. - * @param {IVendor[]} vendors - Customers. + * @param {IVendor[]} vendors - Customers. * @returns {IVendorBalanceSummaryVendor[]} */ private vendorsMapper = ( @@ -77,7 +77,7 @@ export class VendorBalanceSummaryReport extends ContactBalanceSummaryReport { /** * Retrieve the vendors sections of the report. - * @param {IVendor} vendors + * @param {IVendor} vendors * @returns {IVendorBalanceSummaryVendor[]} */ private getVendorsSection(vendors: IVendor[]): IVendorBalanceSummaryVendor[] { diff --git a/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryApplication.ts b/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryApplication.ts index 5fe4bc74d..c02eac224 100644 --- a/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryApplication.ts +++ b/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryApplication.ts @@ -3,6 +3,7 @@ import { IVendorBalanceSummaryQuery } from '@/interfaces'; import { VendorBalanceSummaryTableInjectable } from './VendorBalanceSummaryTableInjectable'; import { VendorBalanceSummaryExportInjectable } from './VendorBalanceSummaryExportInjectable'; import { VendorBalanceSummaryService } from './VendorBalanceSummaryService'; +import { VendorBalanceSummaryPdf } from './VendorBalanceSummaryPdf'; @Service() export class VendorBalanceSummaryApplication { @@ -15,6 +16,9 @@ export class VendorBalanceSummaryApplication { @Inject() private vendorBalanceSummaryExport: VendorBalanceSummaryExportInjectable; + @Inject() + private vendorBalanceSummaryPdf: VendorBalanceSummaryPdf; + /** * Retrieves the vendor balance summary sheet in sheet format. * @param {number} tenantId @@ -59,4 +63,14 @@ export class VendorBalanceSummaryApplication { ): Promise { return this.vendorBalanceSummaryExport.csv(tenantId, query); } + + /** + * Retrieves the vendor balance summary sheet in pdf format. + * @param {number} tenantId + * @param {IVendorBalanceSummaryQuery} query + * @returns {Promise} + */ + public pdf(tenantId: number, query: IVendorBalanceSummaryQuery) { + return this.vendorBalanceSummaryPdf.pdf(tenantId, query); + } } diff --git a/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryMeta.ts b/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryMeta.ts new file mode 100644 index 000000000..80071f8ca --- /dev/null +++ b/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryMeta.ts @@ -0,0 +1,32 @@ +import moment from 'moment'; +import { Inject, Service } from 'typedi'; +import { FinancialSheetMeta } from '../FinancialSheetMeta'; +import { + IVendorBalanceSummaryMeta, + IVendorBalanceSummaryQuery, +} from '@/interfaces'; + +@Service() +export class VendorBalanceSummaryMeta { + @Inject() + private financialSheetMeta: FinancialSheetMeta; + + /** + * Retrieves the vendor balance summary meta. + * @param {number} tenantId - + * @returns {IBalanceSheetMeta} + */ + public async meta( + tenantId: number, + query: IVendorBalanceSummaryQuery + ): Promise { + const commonMeta = await this.financialSheetMeta.meta(tenantId); + const formattedAsDate = moment(query.asDate).format('YYYY/MM/DD'); + + return { + ...commonMeta, + sheetName: 'Vendor Balance Summary', + formattedAsDate, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryPdf.ts b/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryPdf.ts new file mode 100644 index 000000000..f13ffc98c --- /dev/null +++ b/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryPdf.ts @@ -0,0 +1,35 @@ +import { Inject, Service } from 'typedi'; +import { IVendorBalanceSummaryQuery } from '@/interfaces'; +import { TableSheetPdf } from '../TableSheetPdf'; +import { VendorBalanceSummaryTableInjectable } from './VendorBalanceSummaryTableInjectable'; +import { HtmlTableCustomCss } from './constants'; + +@Service() +export class VendorBalanceSummaryPdf { + @Inject() + private vendorBalanceSummaryTable: VendorBalanceSummaryTableInjectable; + + @Inject() + private tableSheetPdf: TableSheetPdf; + + /** + * Retrieves the sales by items sheet in pdf format. + * @param {number} tenantId + * @param {number} query + * @returns {Promise} + */ + public async pdf( + tenantId: number, + query: IVendorBalanceSummaryQuery + ): Promise { + const table = await this.vendorBalanceSummaryTable.table(tenantId, query); + + return this.tableSheetPdf.convertToPdf( + tenantId, + table.table, + table.meta.sheetName, + table.meta.formattedAsDate, + HtmlTableCustomCss + ); + } +} diff --git a/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryService.ts b/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryService.ts index b1a361b87..c64496b7b 100644 --- a/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryService.ts +++ b/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryService.ts @@ -12,15 +12,21 @@ import { VendorBalanceSummaryReport } from './VendorBalanceSummary'; import Ledger from '@/services/Accounting/Ledger'; import VendorBalanceSummaryRepository from './VendorBalanceSummaryRepository'; import { Tenant } from '@/system/models'; +import { JournalSheetMeta } from '../JournalSheet/JournalSheetMeta'; + +import { VendorBalanceSummaryMeta } from './VendorBalanceSummaryMeta'; export class VendorBalanceSummaryService implements IVendorBalanceSummaryService { @Inject() - tenancy: TenancyService; + private tenancy: TenancyService; @Inject() - reportRepo: VendorBalanceSummaryRepository; + private reportRepo: VendorBalanceSummaryRepository; + + @Inject() + private vendorBalanceSummaryMeta: VendorBalanceSummaryMeta; /** * Defaults balance sheet filter query. @@ -43,6 +49,7 @@ export class VendorBalanceSummaryService } /** + * * Retrieve the vendors ledger entrjes. * @param {number} tenantId - * @param {Date|string} date - @@ -97,10 +104,13 @@ export class VendorBalanceSummaryService filter, tenant.metadata.baseCurrency ); + // Retrieve the vendor balance summary meta. + const meta = await this.vendorBalanceSummaryMeta.meta(tenantId, filter); return { data: reportInstance.reportData(), query: filter, + meta }; } } diff --git a/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryTableInjectable.ts b/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryTableInjectable.ts index aec97e6f1..c055b1151 100644 --- a/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryTableInjectable.ts +++ b/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryTableInjectable.ts @@ -27,10 +27,12 @@ export class VendorBalanceSummaryTableInjectable { ): Promise { const i18n = this.tenancy.i18n(tenantId); - const { data } = await this.vendorBalanceSummarySheet.vendorBalanceSummary( - tenantId, - query - ); + const { data, meta } = + await this.vendorBalanceSummarySheet.vendorBalanceSummary( + + tenantId, + query + ); const table = new VendorBalanceSummaryTable(data, query, i18n); return { @@ -39,6 +41,7 @@ export class VendorBalanceSummaryTableInjectable { rows: table.tableRows(), }, query, + meta, }; } } diff --git a/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryTableRows.ts b/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryTableRows.ts index 2095ea087..56fdbf924 100644 --- a/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryTableRows.ts +++ b/packages/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryTableRows.ts @@ -54,7 +54,7 @@ export class VendorBalanceSummaryTable { */ private getVendorColumnsAccessor = (): IColumnMapperMeta[] => { const columns = [ - { key: 'vendorName', accessor: 'vendorName' }, + { key: 'name', accessor: 'vendorName' }, { key: 'total', accessor: 'total.formattedAmount' }, ]; return R.compose( @@ -87,7 +87,7 @@ export class VendorBalanceSummaryTable { */ private getTotalColumnsAccessor = (): IColumnMapperMeta[] => { const columns = [ - { key: 'total', value: this.i18n.__('Total') }, + { key: 'name', value: this.i18n.__('Total') }, { key: 'total', accessor: 'total.formattedAmount' }, ]; return R.compose( diff --git a/packages/server/src/services/FinancialStatements/VendorBalanceSummary/constants.ts b/packages/server/src/services/FinancialStatements/VendorBalanceSummary/constants.ts new file mode 100644 index 000000000..513a3dcdb --- /dev/null +++ b/packages/server/src/services/FinancialStatements/VendorBalanceSummary/constants.ts @@ -0,0 +1,14 @@ +export const HtmlTableCustomCss = ` +table tr.row-type--total td { + font-weight: 600; + border-top: 1px solid #bbb; + border-bottom: 3px double #333; +} +table .column--name { + width: 65%; +} +table .column--total, +table .cell--total { + text-align: right; +} +`; diff --git a/packages/server/src/services/FinancialStatements/utils.ts b/packages/server/src/services/FinancialStatements/utils.ts index 1114131b9..5d304ca6f 100644 --- a/packages/server/src/services/FinancialStatements/utils.ts +++ b/packages/server/src/services/FinancialStatements/utils.ts @@ -1,4 +1,5 @@ - +import { kebabCase } from 'lodash'; +import { ITableRow } from '@/interfaces'; export const formatNumber = (balance, { noCents, divideOn1000 }): string => { let formattedBalance: number = parseFloat(balance); @@ -10,4 +11,20 @@ export const formatNumber = (balance, { noCents, divideOn1000 }): string => { formattedBalance /= 1000; } return formattedBalance; -}; \ No newline at end of file +}; + +export const tableClassNames = (rows: ITableRow[]) => { + return rows.map((row) => { + const classNames = + row?.rowTypes?.map((rowType) => `row-type--${kebabCase(rowType)}`) || []; + + if (row.id) { + classNames.push(`row-id--${kebabCase(row.id)}`); + } + + return { + ...row, + classNames, + }; + }); +}; diff --git a/packages/server/src/services/MailNotification/ContactMailNotification.ts b/packages/server/src/services/MailNotification/ContactMailNotification.ts new file mode 100644 index 000000000..e1e733a79 --- /dev/null +++ b/packages/server/src/services/MailNotification/ContactMailNotification.ts @@ -0,0 +1,106 @@ +import { Inject, Service } from 'typedi'; +import { CommonMailOptions } from '@/interfaces'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { MailTenancy } from '@/services/MailTenancy/MailTenancy'; +import { formatSmsMessage } from '@/utils'; +import { Tenant } from '@/system/models'; + +@Service() +export class ContactMailNotification { + @Inject() + private mailTenancy: MailTenancy; + + @Inject() + private tenancy: HasTenancyService; + + /** + * Parses the default message options. + * @param {number} tenantId - + * @param {number} invoiceId - + * @param {string} subject - + * @param {string} body - + * @returns {Promise} + */ + public async getDefaultMailOptions( + tenantId: number, + contactId: number, + subject: string = '', + body: string = '' + ): Promise { + const { Customer } = this.tenancy.models(tenantId); + const contact = await Customer.query() + .findById(contactId) + .throwIfNotFound(); + + const toAddresses = contact.contactAddresses; + const fromAddresses = await this.mailTenancy.senders(tenantId); + + const toAddress = toAddresses.find((a) => a.primary); + const fromAddress = fromAddresses.find((a) => a.primary); + + const to = toAddress?.mail || ''; + const from = fromAddress?.mail || ''; + + return { + subject, + body, + to, + from, + fromAddresses, + toAddresses, + }; + } + + /** + * Retrieves the mail options of the given contact. + * @param {number} tenantId - Tenant id. + * @param {number} invoiceId - Invoice id. + * @param {string} defaultSubject - Default subject text. + * @param {string} defaultBody - Default body text. + * @returns {Promise} + */ + public async getMailOptions( + tenantId: number, + contactId: number, + defaultSubject?: string, + defaultBody?: string, + formatterData?: Record + ): Promise { + const mailOpts = await this.getDefaultMailOptions( + tenantId, + contactId, + defaultSubject, + defaultBody + ); + const commonFormatArgs = await this.getCommonFormatArgs(tenantId); + const formatArgs = { + ...commonFormatArgs, + ...formatterData, + }; + const subject = formatSmsMessage(mailOpts.subject, formatArgs); + const body = formatSmsMessage(mailOpts.body, formatArgs); + + return { + ...mailOpts, + subject, + body, + }; + } + + /** + * Retrieves the common format args. + * @param {number} tenantId + * @returns {Promise>} + */ + public async getCommonFormatArgs( + tenantId: number + ): Promise> { + const organization = await Tenant.query() + .findById(tenantId) + .withGraphFetched('metadata'); + + return { + CompanyName: organization.metadata.name, + }; + } +} diff --git a/packages/server/src/services/MailNotification/constants.ts b/packages/server/src/services/MailNotification/constants.ts new file mode 100644 index 000000000..95b720d70 --- /dev/null +++ b/packages/server/src/services/MailNotification/constants.ts @@ -0,0 +1,6 @@ +export const ERRORS = { + MAIL_FROM_NOT_FOUND: 'Mail from address not found', + MAIL_TO_NOT_FOUND: 'Mail to address not found', + MAIL_SUBJECT_NOT_FOUND: 'Mail subject not found', + MAIL_BODY_NOT_FOUND: 'Mail body not found', +}; diff --git a/packages/server/src/services/MailNotification/utils.ts b/packages/server/src/services/MailNotification/utils.ts new file mode 100644 index 000000000..b9e37b297 --- /dev/null +++ b/packages/server/src/services/MailNotification/utils.ts @@ -0,0 +1,33 @@ +import { isEmpty } from 'lodash'; +import { ServiceError } from '@/exceptions'; +import { CommonMailOptions, CommonMailOptionsDTO } from '@/interfaces'; +import { ERRORS } from './constants'; + +/** + * Merges the mail options with incoming options. + * @param {Partial} mailOptions + * @param {Partial} overridedOptions + * @throws {ServiceError} + */ +export function parseAndValidateMailOptions( + mailOptions: Partial, + overridedOptions: Partial +) { + const mergedMessageOptions = { + ...mailOptions, + ...overridedOptions, + }; + if (isEmpty(mergedMessageOptions.from)) { + throw new ServiceError(ERRORS.MAIL_FROM_NOT_FOUND); + } + if (isEmpty(mergedMessageOptions.to)) { + throw new ServiceError(ERRORS.MAIL_TO_NOT_FOUND); + } + if (isEmpty(mergedMessageOptions.subject)) { + throw new ServiceError(ERRORS.MAIL_SUBJECT_NOT_FOUND); + } + if (isEmpty(mergedMessageOptions.body)) { + throw new ServiceError(ERRORS.MAIL_BODY_NOT_FOUND); + } + return mergedMessageOptions; +} diff --git a/packages/server/src/services/MailTenancy/MailTenancy.ts b/packages/server/src/services/MailTenancy/MailTenancy.ts new file mode 100644 index 000000000..6f8e82e11 --- /dev/null +++ b/packages/server/src/services/MailTenancy/MailTenancy.ts @@ -0,0 +1,25 @@ +import config from '@/config'; +import { Tenant } from "@/system/models"; +import { Service } from 'typedi'; + + +@Service() +export class MailTenancy { + /** + * Retrieves the senders mails of the given tenant. + * @param {number} tenantId + */ + public async senders(tenantId: number) { + const tenant = await Tenant.query() + .findById(tenantId) + .withGraphFetched('metadata'); + + return [ + { + mail: config.mail.from, + label: tenant.metadata.name, + primary: true, + } + ].filter((item) => item.mail) + } +} \ No newline at end of file diff --git a/packages/server/src/services/Purchases/BillPayments/BillPaymentValidators.ts b/packages/server/src/services/Purchases/BillPayments/BillPaymentValidators.ts index 3aa2902ca..8bbfadbff 100644 --- a/packages/server/src/services/Purchases/BillPayments/BillPaymentValidators.ts +++ b/packages/server/src/services/Purchases/BillPayments/BillPaymentValidators.ts @@ -10,7 +10,6 @@ import { import TenancyService from '@/services/Tenancy/TenancyService'; import { ServiceError } from '@/exceptions'; import { ACCOUNT_TYPE } from '@/data/AccountTypes'; -import { BillPayment } from '@/models'; import { ERRORS } from './constants'; @Service() @@ -18,19 +17,6 @@ export class BillPaymentValidators { @Inject() private tenancy: TenancyService; - /** - * Validates the payment existance. - * @param {BillPayment | undefined | null} payment - * @throws {ServiceError(ERRORS.PAYMENT_MADE_NOT_FOUND)} - */ - public async validateBillPaymentExistance( - payment: BillPayment | undefined | null - ) { - if (!payment) { - throw new ServiceError(ERRORS.PAYMENT_MADE_NOT_FOUND); - } - } - /** * Validates the bill payment existance. * @param {Request} req diff --git a/packages/server/src/services/Purchases/BillPayments/DeleteBillPayment.ts b/packages/server/src/services/Purchases/BillPayments/DeleteBillPayment.ts index 4ab1c9a25..e4a1ab1fa 100644 --- a/packages/server/src/services/Purchases/BillPayments/DeleteBillPayment.ts +++ b/packages/server/src/services/Purchases/BillPayments/DeleteBillPayment.ts @@ -1,6 +1,5 @@ import { Knex } from 'knex'; import UnitOfWork from '@/services/UnitOfWork'; -import { BillPaymentValidators } from './BillPaymentValidators'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { Inject, Service } from 'typedi'; @@ -21,9 +20,6 @@ export class DeleteBillPayment { @Inject() private uow: UnitOfWork; - @Inject() - private validators: BillPaymentValidators; - /** * Deletes the bill payment and associated transactions. * @param {number} tenantId - Tenant id. @@ -36,10 +32,8 @@ export class DeleteBillPayment { // Retrieve the bill payment or throw not found service error. const oldBillPayment = await BillPayment.query() .withGraphFetched('entries') - .findById(billPaymentId); - - // Validates the bill payment existance. - this.validators.validateBillPaymentExistance(oldBillPayment); + .findById(billPaymentId) + .throwIfNotFound(); // Deletes the bill transactions with associated transactions under // unit-of-work envirement. diff --git a/packages/server/src/services/Purchases/BillPayments/EditBillPayment.ts b/packages/server/src/services/Purchases/BillPayments/EditBillPayment.ts index 20c72d38b..de18853bb 100644 --- a/packages/server/src/services/Purchases/BillPayments/EditBillPayment.ts +++ b/packages/server/src/services/Purchases/BillPayments/EditBillPayment.ts @@ -57,12 +57,12 @@ export class EditBillPayment { const tenantMeta = await TenantMetadata.query().findOne({ tenantId }); - const oldBillPayment = await BillPayment.query().findById(billPaymentId); + const oldBillPayment = await BillPayment.query() + .findById(billPaymentId) + .withGraphFetched('entries') + .throwIfNotFound(); - // Validates the bill payment existance. - this.validators.validateBillPaymentExistance(oldBillPayment); - - // + // Retrieves the bill payment vendor or throw not found error. const vendor = await Contact.query() .modify('vendor') .findById(billPaymentDTO.vendorId) @@ -126,7 +126,7 @@ export class EditBillPayment { trx, } as IBillPaymentEditingPayload); - // Deletes the bill payment transaction graph from the storage. + // Edits the bill payment transaction graph on the storage. const billPayment = await BillPayment.query(trx).upsertGraphAndFetch({ id: billPaymentId, ...billPaymentObj, diff --git a/packages/server/src/services/Purchases/BillPayments/GetBillPayment.ts b/packages/server/src/services/Purchases/BillPayments/GetBillPayment.ts index 5984e8932..ccb1cab77 100644 --- a/packages/server/src/services/Purchases/BillPayments/GetBillPayment.ts +++ b/packages/server/src/services/Purchases/BillPayments/GetBillPayment.ts @@ -1,12 +1,8 @@ import { IBillPayment } from '@/interfaces'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { Inject, Service } from 'typedi'; -import { ERRORS } from './constants'; -import { ServiceError } from '@/exceptions'; import { BillPaymentTransformer } from './BillPaymentTransformer'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; -import { BillsValidators } from '../Bills/BillsValidators'; -import { BillPaymentValidators } from './BillPaymentValidators'; @Service() export class GetBillPayment { @@ -16,9 +12,6 @@ export class GetBillPayment { @Inject() private transformer: TransformerInjectable; - @Inject() - private validators: BillPaymentValidators; - /** * Retrieve bill payment. * @param {number} tenantId @@ -37,10 +30,8 @@ export class GetBillPayment { .withGraphFetched('paymentAccount') .withGraphFetched('transactions') .withGraphFetched('branch') - .findById(billPyamentId); - - // Validates the bill payment existance. - this.validators.validateBillPaymentExistance(billPayment); + .findById(billPyamentId) + .throwIfNotFound(); return this.transformer.transform( tenantId, diff --git a/packages/server/src/services/Purchases/BillPayments/GetPaymentBills.ts b/packages/server/src/services/Purchases/BillPayments/GetPaymentBills.ts index 7b86c8f04..ec839411b 100644 --- a/packages/server/src/services/Purchases/BillPayments/GetPaymentBills.ts +++ b/packages/server/src/services/Purchases/BillPayments/GetPaymentBills.ts @@ -18,10 +18,9 @@ export class GetPaymentBills { public async getPaymentBills(tenantId: number, billPaymentId: number) { const { Bill, BillPayment } = this.tenancy.models(tenantId); - const billPayment = await BillPayment.query().findById(billPaymentId); - - // Validates the bill payment existance. - this.validators.validateBillPaymentExistance(billPayment); + const billPayment = await BillPayment.query() + .findById(billPaymentId) + .throwIfNotFound(); const paymentBillsIds = billPayment.entries.map((entry) => entry.id); diff --git a/packages/server/src/services/Purchases/Bills/BillsValidators.ts b/packages/server/src/services/Purchases/Bills/BillsValidators.ts index cba38dfbb..9f209e40d 100644 --- a/packages/server/src/services/Purchases/Bills/BillsValidators.ts +++ b/packages/server/src/services/Purchases/Bills/BillsValidators.ts @@ -21,6 +21,20 @@ export class BillsValidators { } } + /** + * Validates the bill amount is bigger than paid amount. + * @param {number} billAmount + * @param {number} paidAmount + */ + public validateBillAmountBiggerPaidAmount( + billAmount: number, + paidAmount: number, + ) { + if (billAmount < paidAmount) { + throw new ServiceError(ERRORS.BILL_AMOUNT_SMALLER_THAN_PAID_AMOUNT); + } + } + /** * Validates the bill number existance. */ diff --git a/packages/server/src/services/Purchases/Bills/EditBill.ts b/packages/server/src/services/Purchases/Bills/EditBill.ts index 26121c120..7b10bb7f1 100644 --- a/packages/server/src/services/Purchases/Bills/EditBill.ts +++ b/packages/server/src/services/Purchases/Bills/EditBill.ts @@ -103,6 +103,7 @@ export class EditBill { tenantId, billDTO.entries ); + // Transforms the bill DTO to model object. const billObj = await this.transformerDTO.billDTOToModel( tenantId, @@ -111,6 +112,11 @@ export class EditBill { authorizedUser, oldBill ); + // Validate bill total amount should be bigger than paid amount. + this.validators.validateBillAmountBiggerPaidAmount( + billObj.amount, + oldBill.paymentAmount + ); // Validate landed cost entries that have allocated cost could not be deleted. await this.entriesService.validateLandedCostEntriesNotDeleted( oldBill.entries, diff --git a/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts b/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts index 4ec163d8b..4cb92adf9 100644 --- a/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts +++ b/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts @@ -13,6 +13,7 @@ export class PurchaseInvoiceTransformer extends Transformer { return [ 'formattedBillDate', 'formattedDueDate', + 'formattedAmount', 'formattedPaymentAmount', 'formattedBalance', 'formattedDueAmount', diff --git a/packages/server/src/services/Purchases/Bills/constants.ts b/packages/server/src/services/Purchases/Bills/constants.ts index 12afad4c7..9cc8566c8 100644 --- a/packages/server/src/services/Purchases/Bills/constants.ts +++ b/packages/server/src/services/Purchases/Bills/constants.ts @@ -18,6 +18,7 @@ export const ERRORS = { LANDED_COST_ENTRIES_SHOULD_BE_INVENTORY_ITEMS: 'LANDED_COST_ENTRIES_SHOULD_BE_INVENTORY_ITEMS', BILL_HAS_APPLIED_TO_VENDOR_CREDIT: 'BILL_HAS_APPLIED_TO_VENDOR_CREDIT', + BILL_AMOUNT_SMALLER_THAN_PAID_AMOUNT: 'BILL_AMOUNT_SMALLER_THAN_PAID_AMOUNT', }; export const DEFAULT_VIEW_COLUMNS = []; diff --git a/packages/server/src/services/Purchases/VendorCredits/EditVendorCredit.ts b/packages/server/src/services/Purchases/VendorCredits/EditVendorCredit.ts index d76dd3690..a0ff0f421 100644 --- a/packages/server/src/services/Purchases/VendorCredits/EditVendorCredit.ts +++ b/packages/server/src/services/Purchases/VendorCredits/EditVendorCredit.ts @@ -9,6 +9,7 @@ import UnitOfWork from '@/services/UnitOfWork'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import ItemsEntriesService from '@/services/Items/ItemsEntriesService'; import events from '@/subscribers/events'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; @Service() export default class EditVendorCredit extends BaseVendorCredit { @@ -21,6 +22,9 @@ export default class EditVendorCredit extends BaseVendorCredit { @Inject() private itemsEntriesService: ItemsEntriesService; + @Inject() + private tenancy: HasTenancyService; + /** * Deletes the given vendor credit. * @param {number} tenantId - Tenant id. @@ -31,7 +35,7 @@ export default class EditVendorCredit extends BaseVendorCredit { vendorCreditId: number, vendorCreditDTO: IVendorCreditEditDTO ) => { - const { VendorCredit } = this.tenancy.models(tenantId); + const { VendorCredit, Contact } = this.tenancy.models(tenantId); // Retrieve the vendor credit or throw not found service error. const oldVendorCredit = await this.getVendorCreditOrThrowError( diff --git a/packages/server/src/services/Purchases/VendorCredits/VendorCreditTransformer.ts b/packages/server/src/services/Purchases/VendorCredits/VendorCreditTransformer.ts index 3d74ee770..be1431ac2 100644 --- a/packages/server/src/services/Purchases/VendorCredits/VendorCreditTransformer.ts +++ b/packages/server/src/services/Purchases/VendorCredits/VendorCreditTransformer.ts @@ -11,6 +11,7 @@ export class VendorCreditTransformer extends Transformer { public includeAttributes = (): string[] => { return [ 'formattedAmount', + 'formattedSubtotal', 'formattedVendorCreditDate', 'formattedCreditsRemaining', 'entries', @@ -37,6 +38,15 @@ export class VendorCreditTransformer extends Transformer { }); }; + /** + * Retrieves the vendor credit formatted subtotal. + * @param {IVendorCredit} vendorCredit + * @returns {string} + */ + protected formattedSubtotal = (vendorCredit): string => { + return formatNumber(vendorCredit.amount, { money: false }); + }; + /** * Retrieve formatted credits remaining. * @param {IVendorCredit} credit diff --git a/packages/server/src/services/Sales/Estimates/SaleEstimateTransformer.ts b/packages/server/src/services/Sales/Estimates/SaleEstimateTransformer.ts index 1102f7bd0..8cd99a9db 100644 --- a/packages/server/src/services/Sales/Estimates/SaleEstimateTransformer.ts +++ b/packages/server/src/services/Sales/Estimates/SaleEstimateTransformer.ts @@ -10,6 +10,7 @@ export class SaleEstimateTransfromer extends Transformer { */ public includeAttributes = (): string[] => { return [ + 'formattedSubtotal', 'formattedAmount', 'formattedEstimateDate', 'formattedExpirationDate', @@ -76,6 +77,15 @@ export class SaleEstimateTransfromer extends Transformer { }); }; + /** + * Retrieves the formatted invoice subtotal. + * @param {ISaleEstimate} estimate + * @returns {string} + */ + protected formattedSubtotal = (estimate: ISaleEstimate): string => { + return formatNumber(estimate.amount, { money: false }); + }; + /** * Retrieves the entries of the sale estimate. * @param {ISaleEstimate} estimate diff --git a/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts b/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts index 3f63b27de..f1c7b3cdf 100644 --- a/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts +++ b/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts @@ -7,6 +7,8 @@ import { ISaleEstimate, ISaleEstimateDTO, ISalesEstimatesFilter, + SaleEstimateMailOptions, + SaleEstimateMailOptionsDTO, } from '@/interfaces'; import { EditSaleEstimate } from './EditSaleEstimate'; import { DeleteSaleEstimate } from './DeleteSaleEstimate'; @@ -17,6 +19,7 @@ import { ApproveSaleEstimate } from './ApproveSaleEstimate'; import { RejectSaleEstimate } from './RejectSaleEstimate'; import { SaleEstimateNotifyBySms } from './SaleEstimateSmsNotify'; import { SaleEstimatesPdf } from './SaleEstimatesPdf'; +import { SendSaleEstimateMail } from './SendSaleEstimateMail'; @Service() export class SaleEstimatesApplication { @@ -50,6 +53,9 @@ export class SaleEstimatesApplication { @Inject() private saleEstimatesPdfService: SaleEstimatesPdf; + @Inject() + private sendEstimateMailService: SendSaleEstimateMail; + /** * Create a sale estimate. * @param {number} tenantId - The tenant id. @@ -198,15 +204,49 @@ export class SaleEstimatesApplication { }; /** - * + * Retrieve the PDF content of the given sale estimate. * @param {number} tenantId - * @param {} saleEstimate + * @param {number} saleEstimateId * @returns */ - public getSaleEstimatePdf(tenantId: number, saleEstimate) { + public getSaleEstimatePdf(tenantId: number, saleEstimateId: number) { return this.saleEstimatesPdfService.getSaleEstimatePdf( tenantId, - saleEstimate + saleEstimateId + ); + } + + /** + * Send the reminder mail of the given sale estimate. + * @param {number} tenantId + * @param {number} saleEstimateId + * @returns {Promise} + */ + public sendSaleEstimateMail( + tenantId: number, + saleEstimateId: number, + saleEstimateMailOpts: SaleEstimateMailOptionsDTO + ): Promise { + return this.sendEstimateMailService.triggerMail( + tenantId, + saleEstimateId, + saleEstimateMailOpts + ); + } + + /** + * Retrieves the default mail options of the given sale estimate. + * @param {number} tenantId + * @param {number} saleEstimateId + * @returns {Promise} + */ + public getSaleEstimateMail( + tenantId: number, + saleEstimateId: number + ): Promise { + return this.sendEstimateMailService.getMailOptions( + tenantId, + saleEstimateId ); } } diff --git a/packages/server/src/services/Sales/Estimates/SaleEstimatesPdf.ts b/packages/server/src/services/Sales/Estimates/SaleEstimatesPdf.ts index db19743f7..af1d2098c 100644 --- a/packages/server/src/services/Sales/Estimates/SaleEstimatesPdf.ts +++ b/packages/server/src/services/Sales/Estimates/SaleEstimatesPdf.ts @@ -1,6 +1,7 @@ import { Inject, Service } from 'typedi'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; +import { GetSaleEstimate } from './GetSaleEstimate'; @Service() export class SaleEstimatesPdf { @@ -10,11 +11,19 @@ export class SaleEstimatesPdf { @Inject() private templateInjectable: TemplateInjectable; + @Inject() + private getSaleEstimate: GetSaleEstimate; + /** * Retrieve sale invoice pdf content. - * @param {} saleInvoice - + * @param {number} tenantId - + * @param {ISaleInvoice} saleInvoice - */ - async getSaleEstimatePdf(tenantId: number, saleEstimate) { + public async getSaleEstimatePdf(tenantId: number, saleEstimateId: number) { + const saleEstimate = await this.getSaleEstimate.getEstimate( + tenantId, + saleEstimateId + ); const htmlContent = await this.templateInjectable.render( tenantId, 'modules/estimate-regular', diff --git a/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts new file mode 100644 index 000000000..0ac580b90 --- /dev/null +++ b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts @@ -0,0 +1,159 @@ +import { Inject, Service } from 'typedi'; +import Mail from '@/lib/Mail'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { + DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT, + DEFAULT_ESTIMATE_REMINDER_MAIL_SUBJECT, +} from './constants'; +import { SaleEstimatesPdf } from './SaleEstimatesPdf'; +import { GetSaleEstimate } from './GetSaleEstimate'; +import { + ISaleEstimateMailPresendEvent, + SaleEstimateMailOptions, + SaleEstimateMailOptionsDTO, +} from '@/interfaces'; +import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; +import { parseAndValidateMailOptions } from '@/services/MailNotification/utils'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; + +@Service() +export class SendSaleEstimateMail { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private estimatePdf: SaleEstimatesPdf; + + @Inject() + private getSaleEstimateService: GetSaleEstimate; + + @Inject() + private contactMailNotification: ContactMailNotification; + + @Inject('agenda') + private agenda: any; + + @Inject() + private eventPublisher: EventPublisher; + + /** + * Triggers the reminder mail of the given sale estimate. + * @param {number} tenantId - + * @param {number} saleEstimateId - + * @param {SaleEstimateMailOptionsDTO} messageOptions - + * @returns {Promise} + */ + public async triggerMail( + tenantId: number, + saleEstimateId: number, + messageOptions: SaleEstimateMailOptionsDTO + ): Promise { + const payload = { + tenantId, + saleEstimateId, + messageOptions, + }; + await this.agenda.now('sale-estimate-mail-send', payload); + + // Triggers `onSaleEstimatePreMailSend` event. + await this.eventPublisher.emitAsync(events.saleEstimate.onPreMailSend, { + tenantId, + saleEstimateId, + messageOptions, + } as ISaleEstimateMailPresendEvent); + } + + /** + * Formates the text of the mail. + * @param {number} tenantId - Tenant id. + * @param {number} estimateId - Estimate id. + * @returns {Promise>} + */ + public formatterData = async (tenantId: number, estimateId: number) => { + const estimate = await this.getSaleEstimateService.getEstimate( + tenantId, + estimateId + ); + return { + CustomerName: estimate.customer.displayName, + EstimateNumber: estimate.estimateNumber, + EstimateDate: estimate.formattedEstimateDate, + EstimateAmount: estimate.formattedAmount, + EstimateExpirationDate: estimate.formattedExpirationDate, + }; + }; + + /** + * Retrieves the mail options. + * @param {number} tenantId + * @param {number} saleEstimateId + * @returns {Promise} + */ + public getMailOptions = async ( + tenantId: number, + saleEstimateId: number + ): Promise => { + const { SaleEstimate } = this.tenancy.models(tenantId); + + const saleEstimate = await SaleEstimate.query() + .findById(saleEstimateId) + .throwIfNotFound(); + + const formatterData = await this.formatterData(tenantId, saleEstimateId); + + const mailOptions = await this.contactMailNotification.getMailOptions( + tenantId, + saleEstimate.customerId, + DEFAULT_ESTIMATE_REMINDER_MAIL_SUBJECT, + DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT, + formatterData + ); + return { + ...mailOptions, + data: formatterData, + attachEstimate: true, + }; + }; + + /** + * Sends the mail notification of the given sale estimate. + * @param {number} tenantId + * @param {number} saleEstimateId + * @param {SaleEstimateMailOptions} messageOptions + * @returns {Promise} + */ + public async sendMail( + tenantId: number, + saleEstimateId: number, + messageOptions: SaleEstimateMailOptionsDTO + ): Promise { + const localMessageOpts = await this.getMailOptions( + tenantId, + saleEstimateId + ); + // Overrides and validates the given mail options. + const messageOpts = parseAndValidateMailOptions( + localMessageOpts, + messageOptions + ); + const mail = new Mail() + .setSubject(messageOpts.subject) + .setTo(messageOpts.to) + .setContent(messageOpts.body); + + if (messageOpts.attachEstimate) { + const estimatePdfBuffer = await this.estimatePdf.getSaleEstimatePdf( + tenantId, + saleEstimateId + ); + mail.setAttachments([ + { + filename: messageOpts.data?.EstimateNumber || 'estimate.pdf', + content: estimatePdfBuffer, + }, + ]); + } + await mail.send(); + } +} diff --git a/packages/server/src/services/Sales/Estimates/SendSaleEstimateMailJob.ts b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMailJob.ts new file mode 100644 index 000000000..b5e8eda39 --- /dev/null +++ b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMailJob.ts @@ -0,0 +1,36 @@ +import Container, { Service } from 'typedi'; +import { SendSaleEstimateMail } from './SendSaleEstimateMail'; + +@Service() +export class SendSaleEstimateMailJob { + /** + * Constructor method. + */ + constructor(agenda) { + agenda.define( + 'sale-estimate-mail-send', + { priority: 'high', concurrency: 2 }, + this.handler + ); + } + + /** + * Triggers sending invoice mail. + */ + private handler = async (job, done: Function) => { + const { tenantId, saleEstimateId, messageOptions } = job.attrs.data; + const sendSaleEstimateMail = Container.get(SendSaleEstimateMail); + + try { + await sendSaleEstimateMail.sendMail( + tenantId, + saleEstimateId, + messageOptions + ); + done(); + } catch (error) { + console.log(error); + done(error); + } + }; +} diff --git a/packages/server/src/services/Sales/Estimates/constants.ts b/packages/server/src/services/Sales/Estimates/constants.ts index 2b58c74a8..6b689a0e1 100644 --- a/packages/server/src/services/Sales/Estimates/constants.ts +++ b/packages/server/src/services/Sales/Estimates/constants.ts @@ -1,3 +1,18 @@ +export const DEFAULT_ESTIMATE_REMINDER_MAIL_SUBJECT = + 'Estimate {EstimateNumber} is awaiting your approval'; +export const DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT = `

Dear {CustomerName}

+

Thank you for your business, You can view or print your estimate from attachements.

+

+Estimate #{EstimateNumber}
+Expiration Date : {EstimateExpirationDate}
+Amount : {EstimateAmount}
+

+ +

+Regards
+{CompanyName} +

+`; export const ERRORS = { SALE_ESTIMATE_NOT_FOUND: 'SALE_ESTIMATE_NOT_FOUND', @@ -8,7 +23,7 @@ export const ERRORS = { CUSTOMER_HAS_SALES_ESTIMATES: 'CUSTOMER_HAS_SALES_ESTIMATES', SALE_ESTIMATE_NO_IS_REQUIRED: 'SALE_ESTIMATE_NO_IS_REQUIRED', SALE_ESTIMATE_ALREADY_DELIVERED: 'SALE_ESTIMATE_ALREADY_DELIVERED', - SALE_ESTIMATE_ALREADY_APPROVED: 'SALE_ESTIMATE_ALREADY_APPROVED' + SALE_ESTIMATE_ALREADY_APPROVED: 'SALE_ESTIMATE_ALREADY_APPROVED', }; export const DEFAULT_VIEW_COLUMNS = []; diff --git a/packages/server/src/services/Sales/Estimates/subscribers/SaleEstimateMarkApprovedOnMailSent.ts b/packages/server/src/services/Sales/Estimates/subscribers/SaleEstimateMarkApprovedOnMailSent.ts new file mode 100644 index 000000000..99caa3952 --- /dev/null +++ b/packages/server/src/services/Sales/Estimates/subscribers/SaleEstimateMarkApprovedOnMailSent.ts @@ -0,0 +1,43 @@ +import { Inject, Service } from 'typedi'; +import events from '@/subscribers/events'; +import { ISaleEstimateMailPresendEvent } from '@/interfaces'; +import { DeliverSaleEstimate } from '../DeliverSaleEstimate'; +import { ServiceError } from '@/exceptions'; +import { ERRORS } from '../constants'; + +@Service() +export class SaleEstimateMarkApprovedOnMailSent { + @Inject() + private deliverEstimateService: DeliverSaleEstimate; + + /** + * Attaches events. + */ + public attach(bus) { + bus.subscribe(events.saleEstimate.onPreMailSend, this.markEstimateApproved); + } + + /** + * Marks the given estimate approved on submitting mail. + * @param {ISaleEstimateMailPresendEvent} + */ + private markEstimateApproved = async ({ + tenantId, + saleEstimateId, + }: ISaleEstimateMailPresendEvent) => { + try { + await this.deliverEstimateService.deliverSaleEstimate( + tenantId, + saleEstimateId + ); + } catch (error) { + if ( + error instanceof ServiceError && + error.errorType === ERRORS.SALE_ESTIMATE_ALREADY_DELIVERED + ) { + } else { + throw error; + } + } + }; +} diff --git a/packages/server/src/services/Sales/Invoices/DeliverSaleInvoice.ts b/packages/server/src/services/Sales/Invoices/DeliverSaleInvoice.ts index 30ba635a6..f5500a03a 100644 --- a/packages/server/src/services/Sales/Invoices/DeliverSaleInvoice.ts +++ b/packages/server/src/services/Sales/Invoices/DeliverSaleInvoice.ts @@ -4,7 +4,6 @@ import { ServiceError } from '@/exceptions'; import { ISaleInvoiceDeliveringPayload, ISaleInvoiceEventDeliveredPayload, - ISystemUser, } from '@/interfaces'; import { ERRORS } from './constants'; import { Inject, Service } from 'typedi'; @@ -36,8 +35,7 @@ export class DeliverSaleInvoice { */ public async deliverSaleInvoice( tenantId: number, - saleInvoiceId: number, - authorizedUser: ISystemUser + saleInvoiceId: number ): Promise { const { SaleInvoice } = this.tenancy.models(tenantId); diff --git a/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts b/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts index f2245afef..b57f86ed9 100644 --- a/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts +++ b/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts @@ -24,8 +24,7 @@ export class GetSaleInvoice { */ public async getSaleInvoice( tenantId: number, - saleInvoiceId: number, - authorizedUser: ISystemUser + saleInvoiceId: number ): Promise { const { SaleInvoice } = this.tenancy.models(tenantId); diff --git a/packages/server/src/services/Sales/Invoices/GetSaleInvoiceMailReminder.ts b/packages/server/src/services/Sales/Invoices/GetSaleInvoiceMailReminder.ts new file mode 100644 index 000000000..2a65d316e --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/GetSaleInvoiceMailReminder.ts @@ -0,0 +1,3 @@ +export class GetSaleInvoiceMailReminder { + public getInvoiceMailReminder(tenantId: number, saleInvoiceId: number) {} +} diff --git a/packages/server/src/services/Sales/Invoices/ItemEntryTransformer.ts b/packages/server/src/services/Sales/Invoices/ItemEntryTransformer.ts index ad0d88525..dbaea4862 100644 --- a/packages/server/src/services/Sales/Invoices/ItemEntryTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/ItemEntryTransformer.ts @@ -8,7 +8,16 @@ export class ItemEntryTransformer extends Transformer { * @returns {Array} */ public includeAttributes = (): string[] => { - return ['rateFormatted', 'totalFormatted']; + return ['quantityFormatted', 'rateFormatted', 'totalFormatted']; + }; + + /** + * Retrieves the formatted quantitty of item entry. + * @param {IItemEntry} entry + * @returns {string} + */ + protected quantityFormatted = (entry: IItemEntry): string => { + return formatNumber(entry.quantity, { money: false }); }; /** diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts b/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts index 3d17c699c..2bbe7e003 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts @@ -1,7 +1,7 @@ import { Inject, Service } from 'typedi'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; -import { ISaleInvoice } from '@/interfaces'; +import { GetSaleInvoice } from './GetSaleInvoice'; @Service() export class SaleInvoicePdf { @@ -11,16 +11,23 @@ export class SaleInvoicePdf { @Inject() private templateInjectable: TemplateInjectable; + @Inject() + private getInvoiceService: GetSaleInvoice; + /** * Retrieve sale invoice pdf content. * @param {number} tenantId - Tenant Id. * @param {ISaleInvoice} saleInvoice - * @returns {Promise} */ - async saleInvoicePdf( + public async saleInvoicePdf( tenantId: number, - saleInvoice: ISaleInvoice + invoiceId: number ): Promise { + const saleInvoice = await this.getInvoiceService.getSaleInvoice( + tenantId, + invoiceId + ); const htmlContent = await this.templateInjectable.render( tenantId, 'modules/invoice-regular', diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts b/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts index 8a37386f9..bc3f8c24b 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts @@ -11,6 +11,7 @@ import { ISystemUser, ITenantUser, InvoiceNotificationType, + SendInvoiceMailDTO, } from '@/interfaces'; import { Inject, Service } from 'typedi'; import { CreateSaleInvoice } from './CreateSaleInvoice'; @@ -24,6 +25,9 @@ import { WriteoffSaleInvoice } from './WriteoffSaleInvoice'; import { SaleInvoicePdf } from './SaleInvoicePdf'; import { GetInvoicePaymentsService } from './GetInvoicePaymentsService'; import { SaleInvoiceNotifyBySms } from './SaleInvoiceNotifyBySms'; +import { SendInvoiceMailReminder } from './SendSaleInvoiceMailReminder'; +import { SendSaleInvoiceMail } from './SendSaleInvoiceMail'; +import { GetSaleInvoiceMailReminder } from './GetSaleInvoiceMailReminder'; @Service() export class SaleInvoiceApplication { @@ -60,6 +64,15 @@ export class SaleInvoiceApplication { @Inject() private invoiceSms: SaleInvoiceNotifyBySms; + @Inject() + private sendInvoiceReminderService: SendInvoiceMailReminder; + + @Inject() + private sendSaleInvoiceMailService: SendSaleInvoiceMail; + + @Inject() + private getSaleInvoiceReminderService: GetSaleInvoiceMailReminder; + /** * Creates a new sale invoice with associated GL entries. * @param {number} tenantId @@ -236,13 +249,13 @@ export class SaleInvoiceApplication { }; /** - * - * @param {number} tenantId ] - * @param saleInvoice - * @returns + * Retrieves the pdf buffer of the given sale invoice. + * @param {number} tenantId - Tenant id. + * @param {number} saleInvoice + * @returns {Promise} */ - public saleInvoicePdf(tenantId: number, saleInvoice) { - return this.pdfSaleInvoiceService.saleInvoicePdf(tenantId, saleInvoice); + public saleInvoicePdf(tenantId: number, saleInvoiceId: number) { + return this.pdfSaleInvoiceService.saleInvoicePdf(tenantId, saleInvoiceId); } /** @@ -279,4 +292,67 @@ export class SaleInvoiceApplication { invoiceSmsDetailsDTO ); }; + + /** + * Retrieves the metadata of invoice mail reminder. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @returns {} + */ + public getSaleInvoiceMailReminder(tenantId: number, saleInvoiceId: number) { + return this.sendInvoiceReminderService.getMailOption( + tenantId, + saleInvoiceId + ); + } + + /** + * Sends reminder of the given invoice to the invoice's customer. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @returns {} + */ + public sendSaleInvoiceMailReminder( + tenantId: number, + saleInvoiceId: number, + messageDTO: SendInvoiceMailDTO + ) { + return this.sendInvoiceReminderService.triggerMail( + tenantId, + saleInvoiceId, + messageDTO + ); + } + + /** + * Sends the invoice mail of the given sale invoice. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @param {SendInvoiceMailDTO} messageDTO + * @returns {Promise} + */ + public sendSaleInvoiceMail( + tenantId: number, + saleInvoiceId: number, + messageDTO: SendInvoiceMailDTO + ) { + return this.sendSaleInvoiceMailService.triggerMail( + tenantId, + saleInvoiceId, + messageDTO + ); + } + + /** + * Retrieves the default mail options of the given sale invoice. + * @param {number} tenantId + * @param {number} saleInvoiceid + * @returns {Promise} + */ + public getSaleInvoiceMail(tenantId: number, saleInvoiceid: number) { + return this.sendSaleInvoiceMailService.getMailOption( + tenantId, + saleInvoiceid + ); + } } diff --git a/packages/server/src/services/Sales/Invoices/SendInvoiceInvoiceMailCommon.ts b/packages/server/src/services/Sales/Invoices/SendInvoiceInvoiceMailCommon.ts new file mode 100644 index 000000000..52ef46a59 --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SendInvoiceInvoiceMailCommon.ts @@ -0,0 +1,83 @@ +import { Inject, Service } from 'typedi'; +import { SaleInvoiceMailOptions } from '@/interfaces'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { GetSaleInvoice } from './GetSaleInvoice'; +import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; +import { + DEFAULT_INVOICE_MAIL_CONTENT, + DEFAULT_INVOICE_MAIL_SUBJECT, +} from './constants'; + +@Service() +export class SendSaleInvoiceMailCommon { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private getSaleInvoiceService: GetSaleInvoice; + + @Inject() + private contactMailNotification: ContactMailNotification; + + /** + * Retrieves the mail options. + * @param {number} tenantId - Tenant id. + * @param {number} invoiceId - Invoice id. + * @param {string} defaultSubject - Subject text. + * @param {string} defaultBody - Subject body. + * @returns {Promise} + */ + public async getMailOption( + tenantId: number, + invoiceId: number, + defaultSubject: string = DEFAULT_INVOICE_MAIL_SUBJECT, + defaultBody: string = DEFAULT_INVOICE_MAIL_CONTENT + ): Promise { + const { SaleInvoice } = this.tenancy.models(tenantId); + + const saleInvoice = await SaleInvoice.query() + .findById(invoiceId) + .throwIfNotFound(); + + const formatterData = await this.formatText(tenantId, invoiceId); + + const mailOptions = await this.contactMailNotification.getMailOptions( + tenantId, + saleInvoice.customerId, + defaultSubject, + defaultBody, + formatterData + ); + return { + ...mailOptions, + attachInvoice: true, + }; + } + + /** + * Retrieves the formatted text of the given sale invoice. + * @param {number} tenantId - Tenant id. + * @param {number} invoiceId - Sale invoice id. + * @param {string} text - The given text. + * @returns {Promise} + */ + public formatText = async ( + tenantId: number, + invoiceId: number + ): Promise> => { + const invoice = await this.getSaleInvoiceService.getSaleInvoice( + tenantId, + invoiceId + ); + + return { + CustomerName: invoice.customer.displayName, + InvoiceNumber: invoice.invoiceNo, + InvoiceDueAmount: invoice.dueAmountFormatted, + InvoiceDueDate: invoice.dueDateFormatted, + InvoiceDate: invoice.invoiceDateFormatted, + InvoiceAmount: invoice.totalFormatted, + OverdueDays: invoice.overdueDays, + }; + }; +} diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts new file mode 100644 index 000000000..eff8b2603 --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts @@ -0,0 +1,121 @@ +import { Inject, Service } from 'typedi'; +import Mail from '@/lib/Mail'; +import { ISaleInvoiceMailSend, SendInvoiceMailDTO } from '@/interfaces'; +import { SaleInvoicePdf } from './SaleInvoicePdf'; +import { SendSaleInvoiceMailCommon } from './SendInvoiceInvoiceMailCommon'; +import { + DEFAULT_INVOICE_MAIL_CONTENT, + DEFAULT_INVOICE_MAIL_SUBJECT, +} from './constants'; +import { parseAndValidateMailOptions } from '@/services/MailNotification/utils'; +import events from '@/subscribers/events'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; + +@Service() +export class SendSaleInvoiceMail { + @Inject() + private invoicePdf: SaleInvoicePdf; + + @Inject() + private invoiceMail: SendSaleInvoiceMailCommon; + + @Inject('agenda') + private agenda: any; + + @Inject() + private eventPublisher: EventPublisher; + + /** + * Sends the invoice mail of the given sale invoice. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @param {SendInvoiceMailDTO} messageDTO + */ + public async triggerMail( + tenantId: number, + saleInvoiceId: number, + messageOptions: SendInvoiceMailDTO + ) { + const payload = { + tenantId, + saleInvoiceId, + messageOptions, + }; + await this.agenda.now('sale-invoice-mail-send', payload); + + // Triggers the event `onSaleInvoicePreMailSend`. + await this.eventPublisher.emitAsync(events.saleInvoice.onPreMailSend, { + tenantId, + saleInvoiceId, + messageOptions, + } as ISaleInvoiceMailSend); + } + + /** + * Retrieves the mail options of the given sale invoice. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @returns {Promise} + */ + public async getMailOption(tenantId: number, saleInvoiceId: number) { + return this.invoiceMail.getMailOption( + tenantId, + saleInvoiceId, + DEFAULT_INVOICE_MAIL_SUBJECT, + DEFAULT_INVOICE_MAIL_CONTENT + ); + } + + /** + * Triggers the mail invoice. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @param {SendInvoiceMailDTO} messageDTO + * @returns {Promise} + */ + public async sendMail( + tenantId: number, + saleInvoiceId: number, + messageOptions: SendInvoiceMailDTO + ) { + const defaultMessageOpts = await this.getMailOption( + tenantId, + saleInvoiceId + ); + // Merge message opts with default options and validate the incoming options. + const messageOpts = parseAndValidateMailOptions( + defaultMessageOpts, + messageOptions + ); + const mail = new Mail() + .setSubject(messageOpts.subject) + .setTo(messageOpts.to) + .setContent(messageOpts.body); + + if (messageOpts.attachInvoice) { + // Retrieves document buffer of the invoice pdf document. + const invoicePdfBuffer = await this.invoicePdf.saleInvoicePdf( + tenantId, + saleInvoiceId + ); + mail.setAttachments([ + { filename: 'invoice.pdf', content: invoicePdfBuffer }, + ]); + } + // Triggers the event `onSaleInvoiceSend`. + await this.eventPublisher.emitAsync(events.saleInvoice.onMailSend, { + tenantId, + saleInvoiceId, + messageOptions, + } as ISaleInvoiceMailSend); + + await mail.send(); + + // Triggers the event `onSaleInvoiceSend`. + await this.eventPublisher.emitAsync(events.saleInvoice.onMailSent, { + tenantId, + saleInvoiceId, + messageOptions, + } as ISaleInvoiceMailSend); + } +} diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailJob.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailJob.ts new file mode 100644 index 000000000..9de941f5f --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailJob.ts @@ -0,0 +1,33 @@ +import Container, { Service } from 'typedi'; +import events from '@/subscribers/events'; +import { SendSaleInvoiceMail } from './SendSaleInvoiceMail'; + +@Service() +export class SendSaleInvoiceMailJob { + /** + * Constructor method. + */ + constructor(agenda) { + agenda.define( + 'sale-invoice-mail-send', + { priority: 'high', concurrency: 2 }, + this.handler + ); + } + + /** + * Triggers sending invoice mail. + */ + private handler = async (job, done: Function) => { + const { tenantId, saleInvoiceId, messageOptions } = job.attrs.data; + const sendInvoiceMail = Container.get(SendSaleInvoiceMail); + + try { + await sendInvoiceMail.sendMail(tenantId, saleInvoiceId, messageOptions); + done(); + } catch (error) { + console.log(error); + done(error); + } + }; +} diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts new file mode 100644 index 000000000..7829a3bf2 --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts @@ -0,0 +1,112 @@ +import { Inject, Service } from 'typedi'; +import { + ISaleInvoiceMailSend, + ISaleInvoiceMailSent, + SendInvoiceMailDTO, +} from '@/interfaces'; +import Mail from '@/lib/Mail'; +import { SaleInvoicePdf } from './SaleInvoicePdf'; +import { SendSaleInvoiceMailCommon } from './SendInvoiceInvoiceMailCommon'; +import { + DEFAULT_INVOICE_REMINDER_MAIL_CONTENT, + DEFAULT_INVOICE_REMINDER_MAIL_SUBJECT, +} from './constants'; +import { parseAndValidateMailOptions } from '@/services/MailNotification/utils'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; + +@Service() +export class SendInvoiceMailReminder { + @Inject('agenda') + private agenda: any; + + @Inject() + private invoicePdf: SaleInvoicePdf; + + @Inject() + private invoiceCommonMail: SendSaleInvoiceMailCommon; + + @Inject() + private eventPublisher: EventPublisher; + + /** + * Triggers the reminder mail of the given sale invoice. + * @param {number} tenantId + * @param {number} saleInvoiceId + */ + public async triggerMail( + tenantId: number, + saleInvoiceId: number, + messageOptions: SendInvoiceMailDTO + ) { + const payload = { + tenantId, + saleInvoiceId, + messageOptions, + }; + await this.agenda.now('sale-invoice-reminder-mail-send', payload); + } + + /** + * Retrieves the mail options of the given sale invoice. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @returns {Promise} + */ + public async getMailOption(tenantId: number, saleInvoiceId: number) { + return this.invoiceCommonMail.getMailOption( + tenantId, + saleInvoiceId, + DEFAULT_INVOICE_REMINDER_MAIL_SUBJECT, + DEFAULT_INVOICE_REMINDER_MAIL_CONTENT + ); + } + + /** + * Triggers the mail invoice. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @param {SendInvoiceMailDTO} messageOptions + * @returns {Promise} + */ + public async sendMail( + tenantId: number, + saleInvoiceId: number, + messageOptions: SendInvoiceMailDTO + ) { + const localMessageOpts = await this.getMailOption(tenantId, saleInvoiceId); + + const messageOpts = parseAndValidateMailOptions( + localMessageOpts, + messageOptions + ); + const mail = new Mail() + .setSubject(messageOpts.subject) + .setTo(messageOpts.to) + .setContent(messageOpts.body); + + if (messageOpts.attachInvoice) { + // Retrieves document buffer of the invoice pdf document. + const invoicePdfBuffer = await this.invoicePdf.saleInvoicePdf( + tenantId, + saleInvoiceId + ); + mail.setAttachments([ + { filename: 'invoice.pdf', content: invoicePdfBuffer }, + ]); + } + // Triggers the event `onSaleInvoiceSend`. + await this.eventPublisher.emitAsync(events.saleInvoice.onMailReminderSend, { + saleInvoiceId, + messageOptions, + } as ISaleInvoiceMailSend); + + await mail.send(); + + // Triggers the event `onSaleInvoiceSent`. + await this.eventPublisher.emitAsync(events.saleInvoice.onMailReminderSent, { + saleInvoiceId, + messageOptions, + } as ISaleInvoiceMailSent); + } +} diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminderJob.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminderJob.ts new file mode 100644 index 000000000..6570a153f --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminderJob.ts @@ -0,0 +1,32 @@ +import Container, { Service } from 'typedi'; +import { SendInvoiceMailReminder } from './SendSaleInvoiceMailReminder'; + +@Service() +export class SendSaleInvoiceReminderMailJob { + /** + * Constructor method. + */ + constructor(agenda) { + agenda.define( + 'sale-invoice-reminder-mail-send', + { priority: 'high', concurrency: 1 }, + this.handler + ); + } + + /** + * Triggers sending invoice mail. + */ + private handler = async (job, done: Function) => { + const { tenantId, saleInvoiceId, messageOptions } = job.attrs.data; + const sendInvoiceMail = Container.get(SendInvoiceMailReminder); + + try { + await sendInvoiceMail.sendMail(tenantId, saleInvoiceId, messageOptions); + done(); + } catch (error) { + console.log(error); + done(error); + } + }; +} diff --git a/packages/server/src/services/Sales/Invoices/constants.ts b/packages/server/src/services/Sales/Invoices/constants.ts index 018dec027..404b7e613 100644 --- a/packages/server/src/services/Sales/Invoices/constants.ts +++ b/packages/server/src/services/Sales/Invoices/constants.ts @@ -1,3 +1,35 @@ +export const DEFAULT_INVOICE_MAIL_SUBJECT = + 'Invoice {InvoiceNumber} from {CompanyName}'; +export const DEFAULT_INVOICE_MAIL_CONTENT = ` +

Dear {CustomerName}

+

Thank you for your business, You can view or print your invoice from attachements.

+

+Invoice #{InvoiceNumber}
+Due Date : {InvoiceDueDate}
+Amount : {InvoiceAmount}
+

+ +

+Regards
+{CompanyName} +

+`; + +export const DEFAULT_INVOICE_REMINDER_MAIL_SUBJECT = + 'Invoice {InvoiceNumber} reminder from {CompanyName}'; +export const DEFAULT_INVOICE_REMINDER_MAIL_CONTENT = ` +

Dear {CustomerName}

+

You might have missed the payment date and the invoice is now overdue by {OverdueDays} days.

+

Invoice #{InvoiceNumber}
+Due Date : {InvoiceDueDate}
+Amount : {InvoiceAmount}

+ +

+Regards
+{CompanyName} +

+`; + export const ERRORS = { INVOICE_NUMBER_NOT_UNIQUE: 'INVOICE_NUMBER_NOT_UNIQUE', SALE_INVOICE_NOT_FOUND: 'SALE_INVOICE_NOT_FOUND', @@ -16,6 +48,7 @@ export const ERRORS = { PAYMENT_ACCOUNT_CURRENCY_INVALID: 'PAYMENT_ACCOUNT_CURRENCY_INVALID', SALE_INVOICE_ALREADY_WRITTEN_OFF: 'SALE_INVOICE_ALREADY_WRITTEN_OFF', SALE_INVOICE_NOT_WRITTEN_OFF: 'SALE_INVOICE_NOT_WRITTEN_OFF', + NO_INVOICE_CUSTOMER_EMAIL_ADDR: 'NO_INVOICE_CUSTOMER_EMAIL_ADDR', }; export const DEFAULT_VIEW_COLUMNS = []; diff --git a/packages/server/src/services/Sales/Invoices/subscribers/InvoiceChangeStatusOnMailSentSubscriber.ts b/packages/server/src/services/Sales/Invoices/subscribers/InvoiceChangeStatusOnMailSentSubscriber.ts new file mode 100644 index 000000000..fc4392777 --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/subscribers/InvoiceChangeStatusOnMailSentSubscriber.ts @@ -0,0 +1,49 @@ +import { Inject, Service } from 'typedi'; +import events from '@/subscribers/events'; +import { ISaleInvoiceMailSent } from '@/interfaces'; +import { DeliverSaleInvoice } from '../DeliverSaleInvoice'; +import { ServiceError } from '@/exceptions'; +import { ERRORS } from '../constants'; + +@Service() +export class InvoiceChangeStatusOnMailSentSubscriber { + @Inject() + private markInvoiceDelivedService: DeliverSaleInvoice; + + /** + * Attaches events. + */ + public attach(bus) { + bus.subscribe(events.saleInvoice.onPreMailSend, this.markInvoiceDelivered); + bus.subscribe( + events.saleInvoice.onMailReminderSent, + this.markInvoiceDelivered + ); + } + + /** + * Marks the invoice delivered once the invoice mail sent. + * @param {ISaleInvoiceMailSent} + * @returns {Promise} + */ + private markInvoiceDelivered = async ({ + tenantId, + saleInvoiceId, + messageOptions, + }: ISaleInvoiceMailSent) => { + try { + await this.markInvoiceDelivedService.deliverSaleInvoice( + tenantId, + saleInvoiceId + ); + } catch (error) { + if ( + error instanceof ServiceError && + error.errorType === ERRORS.SALE_INVOICE_ALREADY_DELIVERED + ) { + } else { + throw error; + } + } + }; +} diff --git a/packages/server/src/services/Sales/PaymentReceives/EditPaymentReceive.ts b/packages/server/src/services/Sales/PaymentReceives/EditPaymentReceive.ts index b99413748..635f48946 100644 --- a/packages/server/src/services/Sales/PaymentReceives/EditPaymentReceive.ts +++ b/packages/server/src/services/Sales/PaymentReceives/EditPaymentReceive.ts @@ -61,7 +61,8 @@ export class EditPaymentReceive { // Validate the payment receive existance. const oldPaymentReceive = await PaymentReceive.query() .withGraphFetched('entries') - .findById(paymentReceiveId); + .findById(paymentReceiveId) + .throwIfNotFound(); // Validates the payment existance. this.validators.validatePaymentExistance(oldPaymentReceive); diff --git a/packages/server/src/services/Sales/PaymentReceives/GetPaymentReeceivePdf.ts b/packages/server/src/services/Sales/PaymentReceives/GetPaymentReeceivePdf.ts index e05937f76..e3d3cfb26 100644 --- a/packages/server/src/services/Sales/PaymentReceives/GetPaymentReeceivePdf.ts +++ b/packages/server/src/services/Sales/PaymentReceives/GetPaymentReeceivePdf.ts @@ -1,7 +1,7 @@ import { Inject, Service } from 'typedi'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; -import { IPaymentReceive } from '@/interfaces'; +import { GetPaymentReceive } from './GetPaymentReceive'; @Service() export default class GetPaymentReceivePdf { @@ -11,6 +11,9 @@ export default class GetPaymentReceivePdf { @Inject() private templateInjectable: TemplateInjectable; + @Inject() + private getPaymentService: GetPaymentReceive; + /** * Retrieve sale invoice pdf content. * @param {number} tenantId - @@ -19,8 +22,12 @@ export default class GetPaymentReceivePdf { */ async getPaymentReceivePdf( tenantId: number, - paymentReceive: IPaymentReceive + paymentReceiveId: number ): Promise { + const paymentReceive = await this.getPaymentService.getPaymentReceive( + tenantId, + paymentReceiveId + ); const htmlContent = await this.templateInjectable.render( tenantId, 'modules/payment-receive-standard', diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveEntryTransformer.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveEntryTransformer.ts index fe9fe9b67..7a7af28c1 100644 --- a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveEntryTransformer.ts +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveEntryTransformer.ts @@ -8,7 +8,7 @@ export class PaymentReceiveEntryTransfromer extends Transformer { * @returns {Array} */ public includeAttributes = (): string[] => { - return ['paymentAmountFormatted', 'entry']; + return ['paymentAmountFormatted', 'invoice']; }; /** diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts new file mode 100644 index 000000000..bd8d4fa64 --- /dev/null +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts @@ -0,0 +1,141 @@ +import { Inject, Service } from 'typedi'; +import { + PaymentReceiveMailOpts, + PaymentReceiveMailOptsDTO, + PaymentReceiveMailPresendEvent, + SendInvoiceMailDTO, +} from '@/interfaces'; +import Mail from '@/lib/Mail'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { + DEFAULT_PAYMENT_MAIL_CONTENT, + DEFAULT_PAYMENT_MAIL_SUBJECT, +} from './constants'; +import { GetPaymentReceive } from './GetPaymentReceive'; +import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; +import { parseAndValidateMailOptions } from '@/services/MailNotification/utils'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; + +@Service() +export class SendPaymentReceiveMailNotification { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private getPaymentService: GetPaymentReceive; + + @Inject() + private contactMailNotification: ContactMailNotification; + + @Inject('agenda') + private agenda: any; + + @Inject() + private eventPublisher: EventPublisher; + + /** + * Sends the mail of the given payment receive. + * @param {number} tenantId + * @param {number} paymentReceiveId + * @param {PaymentReceiveMailOptsDTO} messageDTO + * @returns {Promise} + */ + public async triggerMail( + tenantId: number, + paymentReceiveId: number, + messageDTO: PaymentReceiveMailOptsDTO + ): Promise { + const payload = { + tenantId, + paymentReceiveId, + messageDTO, + }; + await this.agenda.now('payment-receive-mail-send', payload); + + // Triggers `onPaymentReceivePreMailSend` event. + await this.eventPublisher.emitAsync(events.paymentReceive.onPreMailSend, { + tenantId, + paymentReceiveId, + messageOptions: messageDTO, + } as PaymentReceiveMailPresendEvent); + } + + /** + * Retrieves the default payment mail options. + * @param {number} tenantId - Tenant id. + * @param {number} paymentReceiveId - Payment receive id. + * @returns {Promise} + */ + public getMailOptions = async ( + tenantId: number, + paymentId: number + ): Promise => { + const { PaymentReceive } = this.tenancy.models(tenantId); + + const paymentReceive = await PaymentReceive.query() + .findById(paymentId) + .throwIfNotFound(); + + const formatterData = await this.textFormatter(tenantId, paymentId); + + return this.contactMailNotification.getMailOptions( + tenantId, + paymentReceive.customerId, + DEFAULT_PAYMENT_MAIL_SUBJECT, + DEFAULT_PAYMENT_MAIL_CONTENT, + formatterData + ); + }; + + /** + * Retrieves the formatted text of the given sale invoice. + * @param {number} tenantId - Tenant id. + * @param {number} invoiceId - Sale invoice id. + * @param {string} text - The given text. + * @returns {Promise} + */ + public textFormatter = async ( + tenantId: number, + invoiceId: number + ): Promise> => { + const payment = await this.getPaymentService.getPaymentReceive( + tenantId, + invoiceId + ); + return { + CustomerName: payment.customer.displayName, + PaymentNumber: payment.payment_receive_no, + PaymentDate: payment.formattedPaymentDate, + PaymentAmount: payment.formattedAmount, + }; + }; + + /** + * Triggers the mail invoice. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @param {SendInvoiceMailDTO} messageDTO + * @returns {Promise} + */ + public async sendMail( + tenantId: number, + paymentReceiveId: number, + messageDTO: SendInvoiceMailDTO + ): Promise { + const defaultMessageOpts = await this.getMailOptions( + tenantId, + paymentReceiveId + ); + // Parsed message opts with default options. + const parsedMessageOpts = parseAndValidateMailOptions( + defaultMessageOpts, + messageDTO + ); + await new Mail() + .setSubject(parsedMessageOpts.subject) + .setTo(parsedMessageOpts.to) + .setContent(parsedMessageOpts.body) + .send(); + } +} diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotificationJob.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotificationJob.ts new file mode 100644 index 000000000..b29570d42 --- /dev/null +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotificationJob.ts @@ -0,0 +1,32 @@ +import Container, { Service } from 'typedi'; +import { SendPaymentReceiveMailNotification } from './PaymentReceiveMailNotification'; + +@Service() +export class PaymentReceiveMailNotificationJob { + /** + * Constructor method. + */ + constructor(agenda) { + agenda.define( + 'payment-receive-mail-send', + { priority: 'high', concurrency: 2 }, + this.handler + ); + } + + /** + * Triggers sending payment notification via mail. + */ + private handler = async (job, done: Function) => { + const { tenantId, paymentReceiveId, messageDTO } = job.attrs.data; + const paymentMail = Container.get(SendPaymentReceiveMailNotification); + + try { + await paymentMail.sendMail(tenantId, paymentReceiveId, messageDTO); + done(); + } catch (error) { + console.log(error); + done(error); + } + }; +} diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveTransformer.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveTransformer.ts index afce4203b..5ca84db07 100644 --- a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveTransformer.ts +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveTransformer.ts @@ -1,7 +1,6 @@ import { IPaymentReceive, IPaymentReceiveEntry } from '@/interfaces'; import { Transformer } from '@/lib/Transformer/Transformer'; import { formatNumber } from 'utils'; -import { SaleInvoiceTransformer } from '../Invoices/SaleInvoiceTransformer'; import { PaymentReceiveEntryTransfromer } from './PaymentReceiveEntryTransformer'; export class PaymentReceiveTransfromer extends Transformer { @@ -11,6 +10,7 @@ export class PaymentReceiveTransfromer extends Transformer { */ public includeAttributes = (): string[] => { return [ + 'subtotalFormatted', 'formattedPaymentDate', 'formattedAmount', 'formattedExchangeRate', @@ -27,6 +27,18 @@ export class PaymentReceiveTransfromer extends Transformer { return this.formatDate(payment.paymentDate); }; + /** + * Retrieve the formatted payment subtotal. + * @param {IPaymentReceive} payment + * @returns {string} + */ + protected subtotalFormatted = (payment: IPaymentReceive): string => { + return formatNumber(payment.amount, { + currencyCode: payment.currencyCode, + money: false, + }); + }; + /** * Retrieve formatted payment amount. * @param {ISaleInvoice} invoice diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts index afeca6010..0d5669bf8 100644 --- a/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts @@ -7,6 +7,7 @@ import { IPaymentReceiveSmsDetails, IPaymentReceivesFilter, ISystemUser, + PaymentReceiveMailOptsDTO, } from '@/interfaces'; import { Inject, Service } from 'typedi'; import { CreatePaymentReceive } from './CreatePaymentReceive'; @@ -17,7 +18,7 @@ import { GetPaymentReceive } from './GetPaymentReceive'; import { GetPaymentReceiveInvoices } from './GetPaymentReceiveInvoices'; import { PaymentReceiveNotifyBySms } from './PaymentReceiveSmsNotify'; import GetPaymentReceivePdf from './GetPaymentReeceivePdf'; -import { PaymentReceive } from '@/models'; +import { SendPaymentReceiveMailNotification } from './PaymentReceiveMailNotification'; @Service() export class PaymentReceivesApplication { @@ -42,6 +43,9 @@ export class PaymentReceivesApplication { @Inject() private paymentSmsNotify: PaymentReceiveNotifyBySms; + @Inject() + private paymentMailNotify: SendPaymentReceiveMailNotification; + @Inject() private getPaymentReceivePdfService: GetPaymentReceivePdf; @@ -176,18 +180,47 @@ export class PaymentReceivesApplication { }; /** - * Retrieve PDF content of the given payment receive. + * Notify customer via mail about payment receive details. + * @param {number} tenantId + * @param {number} paymentReceiveId + * @param {IPaymentReceiveMailOpts} messageOpts + * @returns {Promise} + */ + public notifyPaymentByMail( + tenantId: number, + paymentReceiveId: number, + messageOpts: PaymentReceiveMailOptsDTO + ): Promise { + return this.paymentMailNotify.triggerMail( + tenantId, + paymentReceiveId, + messageOpts + ); + } + + /** + * Retrieves the default mail options of the given payment transaction. + * @param {number} tenantId + * @param {number} paymentReceiveId + * @returns {Promise} + */ + public getPaymentMailOptions(tenantId: number, paymentReceiveId: number) { + return this.paymentMailNotify.getMailOptions(tenantId, paymentReceiveId); + } + + /** + * Retrieve pdf content of the given payment receive. * @param {number} tenantId * @param {PaymentReceive} paymentReceive * @returns */ public getPaymentReceivePdf = ( tenantId: number, - paymentReceive: PaymentReceive + paymentReceiveId: number ) => { return this.getPaymentReceivePdfService.getPaymentReceivePdf( tenantId, - paymentReceive + paymentReceiveId ); }; } diff --git a/packages/server/src/services/Sales/PaymentReceives/constants.ts b/packages/server/src/services/Sales/PaymentReceives/constants.ts index ccd8d75ee..405939617 100644 --- a/packages/server/src/services/Sales/PaymentReceives/constants.ts +++ b/packages/server/src/services/Sales/PaymentReceives/constants.ts @@ -1,3 +1,18 @@ +export const DEFAULT_PAYMENT_MAIL_SUBJECT = 'Payment Received by {CompanyName}'; +export const DEFAULT_PAYMENT_MAIL_CONTENT = ` +

Dear {CustomerName}

+

Thank you for your payment. It was a pleasure doing business with you. We look forward to work together again!

+

+Payment Date : {PaymentDate}
+Amount : {PaymentAmount}
+

+ +

+Regards
+{CompanyName} +

+`; + export const ERRORS = { PAYMENT_RECEIVE_NO_EXISTS: 'PAYMENT_RECEIVE_NO_EXISTS', PAYMENT_RECEIVE_NOT_EXISTS: 'PAYMENT_RECEIVE_NOT_EXISTS', @@ -12,6 +27,7 @@ export const ERRORS = { PAYMENT_CUSTOMER_SHOULD_NOT_UPDATE: 'PAYMENT_CUSTOMER_SHOULD_NOT_UPDATE', CUSTOMER_HAS_PAYMENT_RECEIVES: 'CUSTOMER_HAS_PAYMENT_RECEIVES', PAYMENT_ACCOUNT_CURRENCY_INVALID: 'PAYMENT_ACCOUNT_CURRENCY_INVALID', + NO_INVOICE_CUSTOMER_EMAIL_ADDR: 'NO_INVOICE_CUSTOMER_EMAIL_ADDR', }; export const DEFAULT_VIEWS = []; diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts index 8dfdd4c75..d4c87df29 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts @@ -5,6 +5,8 @@ import { IPaginationMeta, ISaleReceipt, ISalesReceiptsFilter, + SaleReceiptMailOpts, + SaleReceiptMailOptsDTO, } from '@/interfaces'; import { EditSaleReceipt } from './EditSaleReceipt'; import { GetSaleReceipt } from './GetSaleReceipt'; @@ -13,6 +15,7 @@ import { GetSaleReceipts } from './GetSaleReceipts'; import { CloseSaleReceipt } from './CloseSaleReceipt'; import { SaleReceiptsPdf } from './SaleReceiptsPdfService'; import { SaleReceiptNotifyBySms } from './SaleReceiptNotifyBySms'; +import { SaleReceiptMailNotification } from './SaleReceiptMailNotification'; @Service() export class SaleReceiptApplication { @@ -40,6 +43,9 @@ export class SaleReceiptApplication { @Inject() private saleReceiptNotifyBySmsService: SaleReceiptNotifyBySms; + @Inject() + private saleReceiptNotifyByMailService: SaleReceiptMailNotification; + /** * Creates a new sale receipt with associated entries. * @param {number} tenantId @@ -166,4 +172,39 @@ export class SaleReceiptApplication { saleReceiptId ); } + + /** + * Sends the receipt mail of the given sale receipt. + * @param {number} tenantId + * @param {number} saleReceiptId + * @param {SaleReceiptMailOptsDTO} messageOpts + * @returns {Promise} + */ + public sendSaleReceiptMail( + tenantId: number, + saleReceiptId: number, + messageOpts: SaleReceiptMailOptsDTO + ): Promise { + return this.saleReceiptNotifyByMailService.triggerMail( + tenantId, + saleReceiptId, + messageOpts + ); + } + + /** + * Retrieves the default mail options of the given sale receipt. + * @param {number} tenantId + * @param {number} saleReceiptId + * @returns {Promise} + */ + public getSaleReceiptMail( + tenantId: number, + saleReceiptId: number + ): Promise { + return this.saleReceiptNotifyByMailService.getMailOptions( + tenantId, + saleReceiptId + ); + } } diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptGLEntries.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptGLEntries.ts index df440958f..d354141e9 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptGLEntries.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptGLEntries.ts @@ -32,7 +32,7 @@ export class SaleReceiptGLEntries { ): Promise => { const { SaleReceipt } = this.tenancy.models(tenantId); - const saleReceipt = await SaleReceipt.query() + const saleReceipt = await SaleReceipt.query(trx) .findById(saleReceiptId) .withGraphFetched('entries.item'); diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts new file mode 100644 index 000000000..24add40cc --- /dev/null +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts @@ -0,0 +1,158 @@ +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { Inject, Service } from 'typedi'; +import Mail from '@/lib/Mail'; +import { GetSaleReceipt } from './GetSaleReceipt'; +import { SaleReceiptsPdf } from './SaleReceiptsPdfService'; +import { + DEFAULT_RECEIPT_MAIL_CONTENT, + DEFAULT_RECEIPT_MAIL_SUBJECT, +} from './constants'; +import { + ISaleReceiptMailPresend, + SaleReceiptMailOpts, + SaleReceiptMailOptsDTO, +} from '@/interfaces'; +import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; +import { parseAndValidateMailOptions } from '@/services/MailNotification/utils'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; + +@Service() +export class SaleReceiptMailNotification { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private getSaleReceiptService: GetSaleReceipt; + + @Inject() + private receiptPdfService: SaleReceiptsPdf; + + @Inject() + private contactMailNotification: ContactMailNotification; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject('agenda') + private agenda: any; + + /** + * Sends the receipt mail of the given sale receipt. + * @param {number} tenantId + * @param {number} saleReceiptId + * @param {SaleReceiptMailOptsDTO} messageDTO + */ + public async triggerMail( + tenantId: number, + saleReceiptId: number, + messageOptions: SaleReceiptMailOptsDTO + ) { + const payload = { + tenantId, + saleReceiptId, + messageOpts: messageOptions, + }; + await this.agenda.now('sale-receipt-mail-send', payload); + + // Triggers the event `onSaleReceiptPreMailSend`. + await this.eventPublisher.emitAsync(events.saleReceipt.onPreMailSend, { + tenantId, + saleReceiptId, + messageOptions, + } as ISaleReceiptMailPresend); + } + + /** + * Retrieves the mail options of the given sale receipt. + * @param {number} tenantId + * @param {number} saleReceiptId + * @returns {Promise} + */ + public async getMailOptions( + tenantId: number, + saleReceiptId: number + ): Promise { + const { SaleReceipt } = this.tenancy.models(tenantId); + + const saleReceipt = await SaleReceipt.query() + .findById(saleReceiptId) + .throwIfNotFound(); + + const formattedData = await this.textFormatter(tenantId, saleReceiptId); + + const mailOpts = await this.contactMailNotification.getMailOptions( + tenantId, + saleReceipt.customerId, + DEFAULT_RECEIPT_MAIL_SUBJECT, + DEFAULT_RECEIPT_MAIL_CONTENT, + formattedData + ); + return { + ...mailOpts, + attachReceipt: true, + }; + } + + /** + * Retrieves the formatted text of the given sale receipt. + * @param {number} tenantId - Tenant id. + * @param {number} receiptId - Sale receipt id. + * @param {string} text - The given text. + * @returns {Promise} + */ + public textFormatter = async ( + tenantId: number, + receiptId: number + ): Promise> => { + const receipt = await this.getSaleReceiptService.getSaleReceipt( + tenantId, + receiptId + ); + return { + CustomerName: receipt.customer.displayName, + ReceiptNumber: receipt.receiptNumber, + ReceiptDate: receipt.formattedReceiptDate, + ReceiptAmount: receipt.formattedAmount, + }; + }; + + /** + * Triggers the mail notification of the given sale receipt. + * @param {number} tenantId - Tenant id. + * @param {number} saleReceiptId - Sale receipt id. + * @param {SaleReceiptMailOpts} messageDTO - Overrided message options. + * @returns {Promise} + */ + public async sendMail( + tenantId: number, + saleReceiptId: number, + messageOpts: SaleReceiptMailOptsDTO + ) { + const defaultMessageOpts = await this.getMailOptions( + tenantId, + saleReceiptId + ); + // Merges message opts with default options. + const parsedMessageOpts = parseAndValidateMailOptions( + defaultMessageOpts, + messageOpts + ); + const mail = new Mail() + .setSubject(parsedMessageOpts.subject) + .setTo(parsedMessageOpts.to) + .setContent(parsedMessageOpts.body); + + if (parsedMessageOpts.attachReceipt) { + // Retrieves document buffer of the receipt pdf document. + const receiptPdfBuffer = await this.receiptPdfService.saleReceiptPdf( + tenantId, + saleReceiptId + ); + mail.setAttachments([ + { filename: 'receipt.pdf', content: receiptPdfBuffer }, + ]); + } + await mail.send(); + } +} diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotificationJob.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotificationJob.ts new file mode 100644 index 000000000..f32325114 --- /dev/null +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotificationJob.ts @@ -0,0 +1,36 @@ +import Container, { Service } from 'typedi'; +import { SaleReceiptMailNotification } from './SaleReceiptMailNotification'; + +@Service() +export class SaleReceiptMailNotificationJob { + /** + * Constructor method. + */ + constructor(agenda) { + agenda.define( + 'sale-receipt-mail-send', + { priority: 'high', concurrency: 2 }, + this.handler + ); + } + + /** + * Triggers sending invoice mail. + */ + private handler = async (job, done: Function) => { + const { tenantId, saleReceiptId, messageOpts } = job.attrs.data; + const receiveMailNotification = Container.get(SaleReceiptMailNotification); + + try { + await receiveMailNotification.sendMail( + tenantId, + saleReceiptId, + messageOpts + ); + done(); + } catch (error) { + console.log(error); + done(error); + } + }; +} diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptTransformer.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptTransformer.ts index c8b950711..9e5d3a127 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptTransformer.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptTransformer.ts @@ -12,6 +12,7 @@ export class SaleReceiptTransformer extends Transformer { */ public includeAttributes = (): string[] => { return [ + 'formattedSubtotal', 'formattedAmount', 'formattedReceiptDate', 'formattedClosedAtDate', @@ -37,6 +38,15 @@ export class SaleReceiptTransformer extends Transformer { return this.formatDate(receipt.closedAt); }; + /** + * Retrieves the estimate formatted subtotal. + * @param {ISaleReceipt} receipt + * @returns {string} + */ + protected formattedSubtotal = (receipt: ISaleReceipt): string => { + return formatNumber(receipt.amount, { money: false }); + }; + /** * Retrieve formatted invoice amount. * @param {ISaleReceipt} estimate diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptsPdfService.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptsPdfService.ts index c06263212..cad2b5f93 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptsPdfService.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptsPdfService.ts @@ -1,6 +1,7 @@ import { Inject, Service } from 'typedi'; import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; +import { GetSaleReceipt } from './GetSaleReceipt'; @Service() export class SaleReceiptsPdf { @@ -10,11 +11,20 @@ export class SaleReceiptsPdf { @Inject() private templateInjectable: TemplateInjectable; + @Inject() + private getSaleReceiptService: GetSaleReceipt; + /** - * Retrieve sale invoice pdf content. - * @param {} saleInvoice - + * Retrieves sale invoice pdf content. + * @param {number} tenantId - + * @param {number} saleInvoiceId - + * @returns {Promise} */ - public async saleReceiptPdf(tenantId: number, saleReceipt) { + public async saleReceiptPdf(tenantId: number, saleReceiptId: number) { + const saleReceipt = await this.getSaleReceiptService.getSaleReceipt( + tenantId, + saleReceiptId + ); const htmlContent = await this.templateInjectable.render( tenantId, 'modules/receipt-regular', diff --git a/packages/server/src/services/Sales/Receipts/constants.ts b/packages/server/src/services/Sales/Receipts/constants.ts index bf0cdef18..084af9214 100644 --- a/packages/server/src/services/Sales/Receipts/constants.ts +++ b/packages/server/src/services/Sales/Receipts/constants.ts @@ -1,3 +1,19 @@ +export const DEFAULT_RECEIPT_MAIL_SUBJECT = + 'Receipt {ReceiptNumber} from {CompanyName}'; +export const DEFAULT_RECEIPT_MAIL_CONTENT = ` +

Dear {CustomerName}

+

Thank you for your business, You can view or print your receipt from attachements.

+

+Receipt #{ReceiptNumber}
+Amount : {ReceiptAmount}
+

+ +

+Regards
+{CompanyName} +

+`; + export const ERRORS = { SALE_RECEIPT_NOT_FOUND: 'SALE_RECEIPT_NOT_FOUND', DEPOSIT_ACCOUNT_NOT_FOUND: 'DEPOSIT_ACCOUNT_NOT_FOUND', @@ -6,6 +22,7 @@ export const ERRORS = { SALE_RECEIPT_IS_ALREADY_CLOSED: 'SALE_RECEIPT_IS_ALREADY_CLOSED', SALE_RECEIPT_NO_IS_REQUIRED: 'SALE_RECEIPT_NO_IS_REQUIRED', CUSTOMER_HAS_SALES_INVOICES: 'CUSTOMER_HAS_SALES_INVOICES', + NO_INVOICE_CUSTOMER_EMAIL_ADDR: 'NO_INVOICE_CUSTOMER_EMAIL_ADDR' }; export const DEFAULT_VIEW_COLUMNS = []; diff --git a/packages/server/src/services/Sales/Receipts/subscribers/SaleReceiptCostGLEntriesSubscriber.ts b/packages/server/src/services/Sales/Receipts/subscribers/SaleReceiptCostGLEntriesSubscriber.ts index 5e6311005..39590198b 100644 --- a/packages/server/src/services/Sales/Receipts/subscribers/SaleReceiptCostGLEntriesSubscriber.ts +++ b/packages/server/src/services/Sales/Receipts/subscribers/SaleReceiptCostGLEntriesSubscriber.ts @@ -6,7 +6,7 @@ import { SaleReceiptCostGLEntries } from '../SaleReceiptCostGLEntries'; @Service() export class SaleReceiptCostGLEntriesSubscriber { @Inject() - saleReceiptCostEntries: SaleReceiptCostGLEntries; + private saleReceiptCostEntries: SaleReceiptCostGLEntries; /** * Attaches events. diff --git a/packages/server/src/services/Sales/Receipts/subscribers/SaleReceiptMarkClosedOnMailSentSubcriber.ts b/packages/server/src/services/Sales/Receipts/subscribers/SaleReceiptMarkClosedOnMailSentSubcriber.ts new file mode 100644 index 000000000..3a8d26394 --- /dev/null +++ b/packages/server/src/services/Sales/Receipts/subscribers/SaleReceiptMarkClosedOnMailSentSubcriber.ts @@ -0,0 +1,41 @@ +import { ISaleReceiptMailPresend } from '@/interfaces'; +import events from '@/subscribers/events'; +import { CloseSaleReceipt } from '../CloseSaleReceipt'; +import { Inject, Service } from 'typedi'; +import { ServiceError } from '@/exceptions'; +import { ERRORS } from '../constants'; + +@Service() +export class SaleReceiptMarkClosedOnMailSentSubcriber { + @Inject() + private closeReceiptService: CloseSaleReceipt; + + /** + * Attaches events. + */ + public attach(bus) { + bus.subscribe(events.saleReceipt.onPreMailSend, this.markReceiptClosed); + } + + /** + * Marks the sale receipt closed on submitting mail. + * @param {ISaleReceiptMailPresend} + */ + private markReceiptClosed = async ({ + tenantId, + saleReceiptId, + messageOptions, + }: ISaleReceiptMailPresend) => { + try { + await this.closeReceiptService.closeSaleReceipt(tenantId, saleReceiptId); + } catch (error) { + if ( + error instanceof ServiceError && + error.errorType === ERRORS.SALE_RECEIPT_IS_ALREADY_CLOSED + ) { + } else { + throw error; + } + } + }; +} diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index 24ca0a0a3..0243cd172 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -129,6 +129,16 @@ export default { onNotifySms: 'onSaleInvoiceNotifySms', onNotifiedSms: 'onSaleInvoiceNotifiedSms', + + onNotifyMail: 'onSaleInvoiceNotifyMail', + onNotifyReminderMail: 'onSaleInvoiceNotifyReminderMail', + + onPreMailSend: 'onSaleInvoicePreMailSend', + onMailSend: 'onSaleInvoiceMailSend', + onMailSent: 'onSaleInvoiceMailSent', + + onMailReminderSend: 'onSaleInvoiceMailReminderSend', + onMailReminderSent: 'onSaleInvoiceMailReminderSent', }, /** @@ -160,6 +170,12 @@ export default { onRejecting: 'onSaleEstimateRejecting', onRejected: 'onSaleEstimateRejected', + + onNotifyMail: 'onSaleEstimateNotifyMail', + + onPreMailSend: 'onSaleEstimatePreMailSend', + onMailSend: 'onSaleEstimateMailSend', + onMailSent: 'onSaleEstimateMailSend', }, /** @@ -183,6 +199,10 @@ export default { onNotifySms: 'onSaleReceiptNotifySms', onNotifiedSms: 'onSaleReceiptNotifiedSms', + + onPreMailSend: 'onSaleReceiptPreMailSend', + onMailSend: 'onSaleReceiptMailSend', + onMailSent: 'onSaleReceiptMailSent', }, /** @@ -203,6 +223,10 @@ export default { onNotifySms: 'onPaymentReceiveNotifySms', onNotifiedSms: 'onPaymentReceiveNotifiedSms', + + onPreMailSend: 'onPaymentReceivePreMailSend', + onMailSend: 'onPaymentReceiveMailSend', + onMailSent: 'onPaymentReceiveMailSent', }, /** @@ -368,6 +392,15 @@ export default { onTransactionDeleting: 'onCashflowTransactionDeleting', onTransactionDeleted: 'onCashflowTransactionDeleted', + + onTransactionCategorizing: 'onTransactionCategorizing', + onTransactionCategorized: 'onCashflowTransactionCategorized', + + onTransactionUncategorizing: 'onTransactionUncategorizing', + onTransactionUncategorized: 'onTransactionUncategorized', + + onTransactionCategorizingAsExpense: 'onTransactionCategorizingAsExpense', + onTransactionCategorizedAsExpense: 'onTransactionCategorizedAsExpense', }, /** @@ -575,6 +608,10 @@ export default { onActivated: 'onTaxRateActivated', onInactivating: 'onTaxRateInactivating', - onInactivated: 'onTaxRateInactivated' + onInactivated: 'onTaxRateInactivated', + }, + + plaid: { + onItemCreated: 'onPlaidItemCreated', }, }; diff --git a/packages/server/src/system/migrations/20240222134235_create_plaid_items_table.js b/packages/server/src/system/migrations/20240222134235_create_plaid_items_table.js new file mode 100644 index 000000000..4098bccca --- /dev/null +++ b/packages/server/src/system/migrations/20240222134235_create_plaid_items_table.js @@ -0,0 +1,15 @@ +exports.up = function (knex) { + return knex.schema.createTable('plaid_items', (table) => { + table.bigIncrements('id'); + table + .bigInteger('tenant_id') + .unsigned() + .index() + .references('id') + .inTable('tenants'); + table.string('plaid_item_id'); + table.timestamps(); + }); +}; + +exports.down = (knex) => {}; diff --git a/packages/server/src/system/models/SystemPlaidItem.ts b/packages/server/src/system/models/SystemPlaidItem.ts new file mode 100644 index 000000000..43b8fb4af --- /dev/null +++ b/packages/server/src/system/models/SystemPlaidItem.ts @@ -0,0 +1,49 @@ +import { Model } from 'objection'; +import SystemModel from '@/system/models/SystemModel'; + +export default class SystemPlaidItem extends SystemModel { + tenantId: number; + plaidItemId: string; + + /** + * Table name. + */ + static get tableName() { + return 'plaid_items'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const Tenant = require('system/models/Tenant'); + + return { + /** + * System user may belongs to tenant model. + */ + tenant: { + relation: Model.BelongsToOneRelation, + modelClass: Tenant.default, + join: { + from: 'users.tenantId', + to: 'tenants.id', + }, + }, + }; + } +} diff --git a/packages/server/src/system/models/TenantMetadata.ts b/packages/server/src/system/models/TenantMetadata.ts index 4664cfd6d..98953dd43 100644 --- a/packages/server/src/system/models/TenantMetadata.ts +++ b/packages/server/src/system/models/TenantMetadata.ts @@ -1,6 +1,9 @@ import BaseModel from 'models/Model'; export default class TenantMetadata extends BaseModel { + baseCurrency: string; + name: string; + /** * Table name. */ diff --git a/packages/server/src/system/models/index.ts b/packages/server/src/system/models/index.ts index 44a92ea27..61fa5b708 100644 --- a/packages/server/src/system/models/index.ts +++ b/packages/server/src/system/models/index.ts @@ -3,5 +3,13 @@ import TenantMetadata from './TenantMetadata'; import SystemUser from './SystemUser'; import PasswordReset from './PasswordReset'; import Invite from './Invite'; +import SystemPlaidItem from './SystemPlaidItem'; -export { Tenant, TenantMetadata, SystemUser, PasswordReset, Invite }; +export { + Tenant, + TenantMetadata, + SystemUser, + PasswordReset, + Invite, + SystemPlaidItem, +}; diff --git a/packages/webapp/package.json b/packages/webapp/package.json index d4e1c65d1..9d7ab57ae 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -20,6 +20,13 @@ "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.4.0", "@testing-library/user-event": "^7.2.1", + "@tiptap/extension-color": "latest", + "@tiptap/extension-text-style": "2.1.13", + "@tiptap/core": "2.1.13", + "@tiptap/pm": "2.1.13", + "@tiptap/extension-list-item": "2.1.13", + "@tiptap/react": "2.1.13", + "@tiptap/starter-kit": "2.1.13", "@types/jest": "^26.0.15", "@types/js-money": "^0.6.1", "@types/lodash": "^4.14.172", @@ -33,6 +40,7 @@ "@types/react-transition-group": "^4.4.5", "@types/styled-components": "^5.1.25", "@types/yup": "^0.29.13", + "@types/socket.io-client": "^3.0.0", "@typescript-eslint/eslint-plugin": "^2.10.0", "@typescript-eslint/parser": "^2.10.0", "@welldone-software/why-did-you-render": "^6.0.0-rc.1", @@ -62,6 +70,9 @@ "moment-timezone": "^0.5.33", "path-browserify": "^1.0.1", "prop-types": "15.8.1", + "plaid": "^9.3.0", + "plaid-threads": "^11.4.3", + "react-plaid-link": "^3.2.1", "query-string": "^7.1.1", "ramda": "^0.27.1", "react": "^18.2.0", @@ -104,6 +115,7 @@ "style-loader": "0.23.1", "styled-components": "^5.3.1", "stylis-rtlcss": "^2.1.1", + "socket.io-client": "^4.7.4", "typescript": "^4.8.3", "yup": "^0.28.1" }, diff --git a/packages/webapp/src/components/ContentTabs/ContentTabs.tsx b/packages/webapp/src/components/ContentTabs/ContentTabs.tsx new file mode 100644 index 000000000..58f844782 --- /dev/null +++ b/packages/webapp/src/components/ContentTabs/ContentTabs.tsx @@ -0,0 +1,111 @@ +// @ts-nocheck +import React from 'react'; +import styled from 'styled-components'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; + +const ContentTabsRoot = styled('div')` + display: flex; + gap: 10px; +`; +interface ContentTabItemRootProps { + active?: boolean; +} +const ContentTabItemRoot = styled.button` + flex: 1 0; + background: #fff; + border: 1px solid #e1e2e8; + border-radius: 5px; + padding: 11px; + text-align: left; + cursor: pointer; + + ${(props) => + props.active && + ` + border-color: #1552c8; + box-shadow: 0 0 0 0.25px #1552c8; + + ${ContentTabTitle} { + color: #1552c8; + font-weight: 500; + } + ${ContentTabDesc} { + color: #1552c8; + } + `} + &:hover, + &:active { + border-color: #1552c8; + } +`; +const ContentTabTitle = styled('h3')` + font-size: 14px; + font-weight: 400; + color: #2f343c; +`; +const ContentTabDesc = styled('p')` + margin: 0; + color: #5f6b7c; + margin-top: 4px; + font-size: 12px; +`; + +interface ContentTabsItemProps { + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + active?: boolean; +} + +const ContentTabsItem = ({ + title, + description, + active, + onClick, +}: ContentTabsItemProps) => { + return ( + + {title} + {description} + + ); +}; + +interface ContentTabsProps { + initialValue?: string; + value?: string; + onChange?: (value: string) => void; + children?: React.ReactNode; + className?: string; +} + +export function ContentTabs({ + initialValue, + value, + onChange, + children, + className, +}: ContentTabsProps) { + const [localValue, handleItemChange] = useUncontrolled({ + initialValue, + value, + onChange, + finalValue: '', + }); + const tabs = React.Children.toArray(children); + + return ( + + {tabs.map((tab) => ( + handleItemChange(tab.props?.id)} + /> + ))} + + ); +} + +ContentTabs.Tab = ContentTabsItem; diff --git a/packages/webapp/src/components/ContentTabs/index.ts b/packages/webapp/src/components/ContentTabs/index.ts new file mode 100644 index 000000000..332e23bfb --- /dev/null +++ b/packages/webapp/src/components/ContentTabs/index.ts @@ -0,0 +1 @@ +export * from './ContentTabs'; diff --git a/packages/webapp/src/components/Customers/CustomersSelect.tsx b/packages/webapp/src/components/Customers/CustomersSelect.tsx index 60e030314..067011792 100644 --- a/packages/webapp/src/components/Customers/CustomersSelect.tsx +++ b/packages/webapp/src/components/Customers/CustomersSelect.tsx @@ -34,7 +34,7 @@ function CustomerSelectRoot({ + - + diff --git a/packages/webapp/src/components/Dashboard/DashboardSockets.tsx b/packages/webapp/src/components/Dashboard/DashboardSockets.tsx new file mode 100644 index 000000000..e0e095876 --- /dev/null +++ b/packages/webapp/src/components/Dashboard/DashboardSockets.tsx @@ -0,0 +1,31 @@ +import { useEffect, useRef } from 'react'; +import { useQueryClient } from 'react-query'; +import { io } from 'socket.io-client'; +import t from '@/hooks/query/types'; +import { AppToaster } from '@/components'; +import { Intent } from '@blueprintjs/core'; + +export function DashboardSockets() { + const socket = useRef(); + const client = useQueryClient(); + + useEffect(() => { + socket.current = io('/', { path: '/socket' }); + + socket.current.on('NEW_TRANSACTIONS_DATA', () => { + client.invalidateQueries(t.ACCOUNTS); + client.invalidateQueries(t.ACCOUNT_TRANSACTION); + client.invalidateQueries(t.CASH_FLOW_ACCOUNTS); + client.invalidateQueries(t.CASH_FLOW_TRANSACTIONS); + + AppToaster.show({ + message: 'The Plaid connected accounts have been updated.', + intent: Intent.SUCCESS, + }); + }); + return () => { + socket.current.removeAllListeners(); + socket.current.close(); + }; + }, []); +} diff --git a/packages/webapp/src/components/Dashboard/GlobalHotkeys.tsx b/packages/webapp/src/components/Dashboard/GlobalHotkeys.tsx index 24f4b12d7..6ec3fd02b 100644 --- a/packages/webapp/src/components/Dashboard/GlobalHotkeys.tsx +++ b/packages/webapp/src/components/Dashboard/GlobalHotkeys.tsx @@ -5,12 +5,16 @@ import { useHistory } from 'react-router-dom'; import { getDashboardRoutes } from '@/routes/dashboard'; import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; import withDialogActions from '@/containers/Dialog/withDialogActions'; +import withUniversalSearchActions from '@/containers/UniversalSearch/withUniversalSearchActions'; import { compose } from '@/utils'; function GlobalHotkeys({ // #withDashboardActions - toggleSidebarExpend, + toggleSidebarExpand, + + // withUniversalSearchActions + openGlobalSearch, // #withDialogActions openDialog, @@ -24,7 +28,7 @@ function GlobalHotkeys({ .toString(); const handleSidebarToggleBtn = () => { - toggleSidebarExpend(); + toggleSidebarExpand(); }; useHotkeys( globalHotkeys, @@ -37,10 +41,26 @@ function GlobalHotkeys({ }, [history], ); - useHotkeys('ctrl+/', (event, handle) => handleSidebarToggleBtn()); - useHotkeys('shift+d', (event, handle) => openDialog('money-in', {})); - useHotkeys('shift+q', (event, handle) => openDialog('money-out', {})); + useHotkeys('ctrl+/', () => { + handleSidebarToggleBtn(); + }); + useHotkeys('shift+d', () => { + openDialog('money-in', {}); + }); + useHotkeys('shift+q', () => { + openDialog('money-out', {}); + }); + useHotkeys('/', () => { + setTimeout(() => { + openGlobalSearch(); + }, 0); + }); + return
; } -export default compose(withDashboardActions, withDialogActions)(GlobalHotkeys); +export default compose( + withDashboardActions, + withDialogActions, + withUniversalSearchActions, +)(GlobalHotkeys); diff --git a/packages/webapp/src/components/Details/index.tsx b/packages/webapp/src/components/Details/index.tsx index e6803b069..9879fc7c0 100644 --- a/packages/webapp/src/components/Details/index.tsx +++ b/packages/webapp/src/components/Details/index.tsx @@ -66,7 +66,7 @@ export function DetailItem({ label, children, name, align, className }) { > {label} -
{children}
+
{children}
); } diff --git a/packages/webapp/src/components/DialogsContainer.tsx b/packages/webapp/src/components/DialogsContainer.tsx index 2ee579980..fc1195545 100644 --- a/packages/webapp/src/components/DialogsContainer.tsx +++ b/packages/webapp/src/components/DialogsContainer.tsx @@ -3,8 +3,6 @@ import InviteUserDialog from '@/containers/Dialogs/InviteUserDialog'; import UserFormDialog from '@/containers/Dialogs/UserFormDialog'; import ItemCategoryDialog from '@/containers/Dialogs/ItemCategoryDialog'; import CurrencyFormDialog from '@/containers/Dialogs/CurrencyFormDialog'; -import ExchangeRateFormDialog from '@/containers/Dialogs/ExchangeRateFormDialog'; - import InventoryAdjustmentDialog from '@/containers/Dialogs/InventoryAdjustmentFormDialog'; import PaymentViaVoucherDialog from '@/containers/Dialogs/PaymentViaVoucherDialog'; import KeyboardShortcutsDialog from '@/containers/Dialogs/keyboardShortcutsDialog'; @@ -47,6 +45,12 @@ import ProjectInvoicingFormDialog from '@/containers/Projects/containers/Project import ProjectBillableEntriesFormDialog from '@/containers/Projects/containers/ProjectBillableEntriesFormDialog'; import TaxRateFormDialog from '@/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialog'; import { DialogsName } from '@/constants/dialogs'; +import InvoiceExchangeRateChangeDialog from '@/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog'; +import InvoiceMailDialog from '@/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialog'; +import EstimateMailDialog from '@/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog'; +import ReceiptMailDialog from '@/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog'; +import PaymentMailDialog from '@/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialog'; +import { ConnectBankDialog } from '@/containers/CashFlow/ConnectBankDialog'; /** * Dialogs container. @@ -58,7 +62,6 @@ export default function DialogsContainer() { - + + + + + + ); } diff --git a/packages/webapp/src/components/Drawer/DrawerBody.tsx b/packages/webapp/src/components/Drawer/DrawerBody.tsx index be7f34ac4..e6bab2b05 100644 --- a/packages/webapp/src/components/Drawer/DrawerBody.tsx +++ b/packages/webapp/src/components/Drawer/DrawerBody.tsx @@ -1,5 +1,6 @@ // @ts-nocheck import React from 'react'; +import clsx from 'classnames'; import { Classes } from '@blueprintjs/core'; import { LoadingIndicator } from '../Indicator'; @@ -11,8 +12,8 @@ export function DrawerLoading({ loading, mount = false, children }) { ); } -export function DrawerBody({ children }) { - return
{children}
; +export function DrawerBody({ children, className }) { + return
{children}
; } export * from './DrawerActionsBar'; diff --git a/packages/webapp/src/components/DrawersContainer.tsx b/packages/webapp/src/components/DrawersContainer.tsx index c8c04f63a..aeae5c4ec 100644 --- a/packages/webapp/src/components/DrawersContainer.tsx +++ b/packages/webapp/src/components/DrawersContainer.tsx @@ -23,6 +23,7 @@ import WarehouseTransferDetailDrawer from '@/containers/Drawers/WarehouseTransfe import TaxRateDetailsDrawer from '@/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsDrawer'; import { DRAWERS } from '@/constants/drawers'; +import CategorizeTransactionDrawer from '@/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionDrawer'; /** * Drawers container of the dashboard. @@ -61,6 +62,7 @@ export default function DrawersContainer() { name={DRAWERS.WAREHOUSE_TRANSFER_DETAILS} /> + ); } diff --git a/packages/webapp/src/components/ExchangeRate/ExchangeRateInput.tsx b/packages/webapp/src/components/ExchangeRate/ExchangeRateInput.tsx index 9877f5dee..d96d91231 100644 --- a/packages/webapp/src/components/ExchangeRate/ExchangeRateInput.tsx +++ b/packages/webapp/src/components/ExchangeRate/ExchangeRateInput.tsx @@ -1,30 +1,159 @@ // @ts-nocheck -import React from 'react'; +import { useState } from 'react'; import styled from 'styled-components'; -import { ControlGroup } from '@blueprintjs/core'; - +import { useFormikContext } from 'formik'; +import { + Button, + Classes, + ControlGroup, + Intent, + Popover, + Position, + Spinner, +} from '@blueprintjs/core'; import { FlagIcon } from '../Tags'; import { FMoneyInputGroup, FFormGroup } from '../Forms'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; + +interface ExchangeRateValuesBag { + oldExchangeRate: string; + exchangeRate: string; +} + +interface ExchangeRateInputGroupProps { + name: string; + fromCurrency: string; + toCurrency: string; + isLoading?: boolean; + + inputGroupProps?: any; + formGroupProps?: any; + + withPopoverRecalcConfirm?: boolean; + + onRecalcConfirm: (bag: ExchangeRateValuesBag) => void; + onCancel: (bag: ExchangeRateValuesBag) => void; + + isConfirmPopoverOpen?: boolean; + initialConfirmPopoverOpen?: boolean; + onConfirmPopoverOpen?: (isOpen: boolean) => void; +} export function ExchangeRateInputGroup({ + name, fromCurrency, toCurrency, + isLoading, + inputGroupProps, formGroupProps, - name, -}) { + + withPopoverRecalcConfirm = false, + + onRecalcConfirm, + onCancel, + + isConfirmPopoverOpen, + initialConfirmPopoverOpen, + onConfirmPopoverOpen, +}: ExchangeRateInputGroupProps) { + const [isOpen, handlePopoverOpen] = useUncontrolled({ + value: isConfirmPopoverOpen, + initialValue: initialConfirmPopoverOpen, + finalValue: false, + onChange: onConfirmPopoverOpen, + }); + const { values, setFieldValue } = useFormikContext(); + const [oldExchangeRate, setOldExchangeRate] = useState(''); + + const exchangeRate = values[name]; + const exchangeRateValuesBag: ExchangeRateValuesBag = { + exchangeRate, + oldExchangeRate, + }; + // Handle re-calc confirm button click. + const handleRecalcConfirmBtn = () => { + handlePopoverOpen(false); + onRecalcConfirm && onRecalcConfirm(exchangeRateValuesBag); + }; + // Handle cancel button click. + const handleCancelBtn = () => { + handlePopoverOpen(false); + onCancel && onCancel(exchangeRateValuesBag); + }; + // Handle exchange rate field blur. + const handleExchangeRateFieldBlur = (value: string) => { + if (value !== values[name]) { + handlePopoverOpen(true); + setFieldValue(name, value); + setOldExchangeRate(values[name]); + } + }; + + const exchangeRateField = ( + null} + onBlur={handleExchangeRateFieldBlur} + rightElement={isLoading && } + decimalsLimit={5} + {...inputGroupProps} + name={name} + /> + ); + + const popoverConfirmContent = ( + +

+ Are you want to re-calculate item prices based on this exchange rate. +

+
+ + +
+
+ ); + return ( 1 {fromCurrency} = - + + {withPopoverRecalcConfirm ? ( + + {exchangeRateField} + + ) : ( + exchangeRateField + )} {toCurrency} @@ -34,7 +163,7 @@ export function ExchangeRateInputGroup({ } const ExchangeRateField = styled(FMoneyInputGroup)` - max-width: 75px; + max-width: 85px; `; const ExchangeRateSideIcon = styled.div` @@ -57,3 +186,8 @@ const ExchangeFlagIcon = styled(FlagIcon)` margin-left: 5px; display: inline-block; `; + +const PopoverContent = styled('div')` + padding: 20px; + width: 300px; +`; diff --git a/packages/webapp/src/components/FormattedMessage/index.tsx b/packages/webapp/src/components/FormattedMessage/index.tsx index b23418325..6dd6ff98b 100644 --- a/packages/webapp/src/components/FormattedMessage/index.tsx +++ b/packages/webapp/src/components/FormattedMessage/index.tsx @@ -1,8 +1,13 @@ // @ts-nocheck import intl from 'react-intl-universal'; -export function FormattedMessage({ id, values }) { - return intl.get(id, values); +interface FormattedMessageProps { + id: string; + values?: Record; +} + +export function FormattedMessage({ id, values }: FormattedMessageProps) { + return <>{intl.get(id, values)}; } export function FormattedHTMLMessage({ ...args }) { diff --git a/packages/webapp/src/components/Forms/FRichEditor.tsx b/packages/webapp/src/components/Forms/FRichEditor.tsx new file mode 100644 index 000000000..d490f87f5 --- /dev/null +++ b/packages/webapp/src/components/Forms/FRichEditor.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { FieldConfig, FieldProps } from 'formik'; +import { Field } from '@blueprintjs-formik/core'; +import { RichEditor, RichEditorProps } from '../../components/RichEditor'; + +export interface FRichEditorProps + extends Omit, + RichEditorProps { + name: string; + value?: string; +} + +interface FieldToRichEditorProps + extends FieldProps, + Omit {} + +/** + * Transformes the field props to `RichEditor` props. + * @param {FieldToRichEditorProps} + * @returns {HTMLSelectProps} + */ +function fieldToRichEditor({ + field: { onBlur: onFieldBlur, ...field }, + form: { touched, errors, ...form }, + ...props +}: FieldToRichEditorProps): RichEditorProps { + return { + ...field, + ...props, + onChange: (value: string) => { + form.setFieldValue(field.name, value); + }, + }; +} + +/** + * Transformes field props to `RichEditor` props. + * @param {FieldToRichEditorProps} + * @returns {JSX.Element} + */ +function FieldToRichEditor({ ...props }: FieldToRichEditorProps): JSX.Element { + return ; +} + +/** + * Rich editor wrapper to bind with Formik. + * @param {FRichEditorProps} props - + * @returns {JSX.Element} + */ +export function FRichEditor({ ...props }: FRichEditorProps): JSX.Element { + return ; +} diff --git a/packages/webapp/src/components/Forms/Select.tsx b/packages/webapp/src/components/Forms/Select.tsx index cae52b1b8..c77c51ce2 100644 --- a/packages/webapp/src/components/Forms/Select.tsx +++ b/packages/webapp/src/components/Forms/Select.tsx @@ -26,12 +26,12 @@ const SelectButton = styled(Button)` position: relative; padding-right: 30px; - &.bp4-small{ + &.bp4-small { padding-right: 24px; } &:not(.is-selected):not([class*='bp4-intent-']):not(.bp4-minimal) { - color: #5c7080; + color: #8f99a8; } &:after { content: ''; diff --git a/packages/webapp/src/components/Forms/index.tsx b/packages/webapp/src/components/Forms/index.tsx index d4fb2aec0..c638ac029 100644 --- a/packages/webapp/src/components/Forms/index.tsx +++ b/packages/webapp/src/components/Forms/index.tsx @@ -4,4 +4,5 @@ export * from './FMoneyInputGroup'; export * from './BlueprintFormik'; export * from './InputPrependText'; export * from './InputPrependButton'; -export * from './MoneyInputGroup'; \ No newline at end of file +export * from './MoneyInputGroup'; +export * from './FRichEditor'; \ No newline at end of file diff --git a/packages/webapp/src/components/RichEditor/RichEditor.style.scss b/packages/webapp/src/components/RichEditor/RichEditor.style.scss new file mode 100644 index 000000000..942fdf81e --- /dev/null +++ b/packages/webapp/src/components/RichEditor/RichEditor.style.scss @@ -0,0 +1,66 @@ +/* Basic editor styles */ +.tiptap { + color: #222; + + &:focus-visible { + outline: none; + } + + >*+* { + margin-top: 0.75em; + } + + ul, + ol { + padding: 0 1rem; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + line-height: 1.1; + } + + code { + background: rgba(#ffffff, 0.1); + color: rgba(#ffffff, 0.6); + border: 1px solid rgba(#ffffff, 0.1); + border-radius: 0.5rem; + padding: 0.2rem; + } + + pre { + background: rgba(#ffffff, 0.1); + font-family: "JetBrainsMono", monospace; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + + code { + color: inherit; + padding: 0; + background: none; + font-size: 0.8rem; + border: none; + } + } + + img { + max-width: 100%; + height: auto; + } + + blockquote { + margin-left: 0; + padding-left: 1rem; + border-left: 2px solid rgba(#ffffff, 0.4); + + hr { + border: none; + border-top: 2px solid rgba(#ffffff, 0.1); + margin: 2rem 0; + } + } +} \ No newline at end of file diff --git a/packages/webapp/src/components/RichEditor/RichEditor.tsx b/packages/webapp/src/components/RichEditor/RichEditor.tsx new file mode 100644 index 000000000..da82fb09f --- /dev/null +++ b/packages/webapp/src/components/RichEditor/RichEditor.tsx @@ -0,0 +1,58 @@ +// @ts-nocheck +import { Color } from '@tiptap/extension-color'; +import ListItem from '@tiptap/extension-list-item'; +import TextStyle from '@tiptap/extension-text-style'; +import { EditorProvider } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; +import { Box } from '../Layout/Box'; +import './RichEditor.style.scss'; + +const extensions = [ + Color.configure({ types: [TextStyle.name, ListItem.name] }), + TextStyle.configure({ types: [ListItem.name] }), + StarterKit.configure({ + bulletList: { + keepMarks: true, + keepAttributes: false, + }, + orderedList: { + keepMarks: true, + keepAttributes: false, + }, + }), +]; + +export interface RichEditorProps { + value?: string; + initialValue?: string; + onChange?: (value: string) => void; + className?: string; +} +export const RichEditor = ({ + value, + initialValue, + onChange, + className, +}: RichEditorProps) => { + const [content, handleChange] = useUncontrolled({ + value, + initialValue, + onChange, + finalValue: '', + }); + + const handleBlur = ({ editor }) => { + handleChange(editor.getHTML()); + }; + + return ( + + + + ); +}; diff --git a/packages/webapp/src/components/RichEditor/index.ts b/packages/webapp/src/components/RichEditor/index.ts new file mode 100644 index 000000000..226b701f3 --- /dev/null +++ b/packages/webapp/src/components/RichEditor/index.ts @@ -0,0 +1 @@ +export * from './RichEditor'; \ No newline at end of file diff --git a/packages/webapp/src/components/UniversalSearch/UniversalSearch.tsx b/packages/webapp/src/components/UniversalSearch/UniversalSearch.tsx index b91eefc94..9d65ef565 100644 --- a/packages/webapp/src/components/UniversalSearch/UniversalSearch.tsx +++ b/packages/webapp/src/components/UniversalSearch/UniversalSearch.tsx @@ -152,6 +152,7 @@ function UniversalSearchBar({ isOpen, onSearchTypeChange, ...listProps }) { onSearchTypeChange={onSearchTypeChange} /> } + autoFocus={true} /> {listProps.itemList} diff --git a/packages/webapp/src/components/Vendors/VendorsSelect.tsx b/packages/webapp/src/components/Vendors/VendorsSelect.tsx index 4b460d1f4..eba8fc082 100644 --- a/packages/webapp/src/components/Vendors/VendorsSelect.tsx +++ b/packages/webapp/src/components/Vendors/VendorsSelect.tsx @@ -35,7 +35,7 @@ function VendorsSelectRoot({ , href: '/preferences/users', }, + { + text: , + href: '/preferences/estimates', + }, + { + text: , + href: '/preferences/invoices', + }, + { + text: , + href: '/preferences/receipts', + }, + { + text: , + href: '/preferences/credit-notes', + }, { text: , href: '/preferences/currencies', diff --git a/packages/webapp/src/constants/tables.tsx b/packages/webapp/src/constants/tables.tsx index 2770d9cf8..33ad65b3e 100644 --- a/packages/webapp/src/constants/tables.tsx +++ b/packages/webapp/src/constants/tables.tsx @@ -15,6 +15,7 @@ export const TABLES = { EXPENSES: 'expenses', CASHFLOW_ACCOUNTS: 'cashflow_accounts', CASHFLOW_Transactions: 'cashflow_transactions', + UNCATEGORIZED_CASHFLOW_TRANSACTION: 'UNCATEGORIZED_CASHFLOW_TRANSACTION', CREDIT_NOTES: 'credit_notes', VENDOR_CREDITS: 'vendor_credits', WAREHOUSE_TRANSFERS: 'warehouse_transfers', diff --git a/packages/webapp/src/containers/Accounts/AccountsAlerts.tsx b/packages/webapp/src/containers/Accounts/AccountsAlerts.tsx index 228be0edf..3b0205a49 100644 --- a/packages/webapp/src/containers/Accounts/AccountsAlerts.tsx +++ b/packages/webapp/src/containers/Accounts/AccountsAlerts.tsx @@ -8,7 +8,7 @@ const AccountInactivateAlert = React.lazy( () => import('@/containers/Alerts/Accounts/AccountInactivateAlert'), ); const AccountActivateAlert = React.lazy( - () => import('@/containers/Alerts/Accounts/AccountDeleteAlert'), + () => import('@/containers/Alerts/Accounts/AccountActivateAlert'), ); export default [ diff --git a/packages/webapp/src/containers/Accounts/AccountsDataTable.tsx b/packages/webapp/src/containers/Accounts/AccountsDataTable.tsx index dfa4b90be..edcfe2e18 100644 --- a/packages/webapp/src/containers/Accounts/AccountsDataTable.tsx +++ b/packages/webapp/src/containers/Accounts/AccountsDataTable.tsx @@ -32,7 +32,7 @@ function AccountsDataTable({ // #withAlertsDialog openAlert, - // #withDial + // #withDialog openDialog, // #withDrawerActions diff --git a/packages/webapp/src/containers/Accounts/components.tsx b/packages/webapp/src/containers/Accounts/components.tsx index d3c793f24..5b9d2b31b 100644 --- a/packages/webapp/src/containers/Accounts/components.tsx +++ b/packages/webapp/src/containers/Accounts/components.tsx @@ -116,3 +116,16 @@ export function BalanceCell({ cell }) { ); } + +/** + * Balance cell. + */ +export function BankBalanceCell({ cell }) { + const account = cell.row.original; + + return account.amount !== null ? ( + {account.bank_balance_formatted} + ) : ( + + ); +} diff --git a/packages/webapp/src/containers/Accounts/utils.tsx b/packages/webapp/src/containers/Accounts/utils.tsx index 3277df6a6..6e535fd28 100644 --- a/packages/webapp/src/containers/Accounts/utils.tsx +++ b/packages/webapp/src/containers/Accounts/utils.tsx @@ -4,7 +4,7 @@ import intl from 'react-intl-universal'; import { Intent, Tag } from '@blueprintjs/core'; import { If, AppToaster } from '@/components'; -import { NormalCell, BalanceCell } from './components'; +import { NormalCell, BalanceCell, BankBalanceCell } from './components'; import { transformTableStateToQuery, isBlank } from '@/utils'; /** @@ -94,6 +94,15 @@ export const useAccountsTableColumns = () => { width: 75, clickable: true, }, + { + id: 'bank_balance', + Header: 'Bank Balance', + accessor: 'bank_balance_formatted', + Cell: BankBalanceCell, + width: 150, + clickable: true, + align: 'right', + }, { id: 'balance', Header: intl.get('balance'), @@ -119,5 +128,5 @@ export const transformAccountsStateToQuery = (tableState) => { return { ...transformTableStateToQuery(tableState), inactive_mode: tableState.inactiveMode, - } -} \ No newline at end of file + }; +}; diff --git a/packages/webapp/src/containers/Alerts/CashFlow/AccountDeleteTransactionAlert.tsx b/packages/webapp/src/containers/Alerts/CashFlow/AccountDeleteTransactionAlert.tsx index 02df2c71c..e50038a66 100644 --- a/packages/webapp/src/containers/Alerts/CashFlow/AccountDeleteTransactionAlert.tsx +++ b/packages/webapp/src/containers/Alerts/CashFlow/AccountDeleteTransactionAlert.tsx @@ -56,7 +56,21 @@ function AccountDeleteTransactionAlert({ response: { data: { errors }, }, - }) => {}, + }) => { + if ( + errors.find( + (e) => + e.type === + 'CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED', + ) + ) { + AppToaster.show({ + message: + 'Cannot delete transaction converted from uncategorized transaction but you uncategorize it.', + intent: Intent.DANGER, + }); + } + }, ) .finally(() => { closeAlert(name); diff --git a/packages/webapp/src/containers/AlertsContainer/registered.tsx b/packages/webapp/src/containers/AlertsContainer/registered.tsx index 417583f60..9bf51e222 100644 --- a/packages/webapp/src/containers/AlertsContainer/registered.tsx +++ b/packages/webapp/src/containers/AlertsContainer/registered.tsx @@ -12,7 +12,6 @@ import PaymentMadesAlerts from '@/containers/Purchases/PaymentMades/PaymentMades import CustomersAlerts from '@/containers/Customers/CustomersAlerts'; import VendorsAlerts from '@/containers/Vendors/VendorsAlerts'; import ManualJournalsAlerts from '@/containers/Accounting/JournalsLanding/ManualJournalsAlerts'; -import ExchangeRatesAlerts from '@/containers/ExchangeRates/ExchangeRatesAlerts'; import ExpensesAlerts from '@/containers/Expenses/ExpensesAlerts'; import AccountTransactionsAlerts from '@/containers/CashFlow/AccountTransactions/AccountTransactionsAlerts'; import UsersAlerts from '@/containers/Preferences/Users/UsersAlerts'; @@ -26,6 +25,7 @@ import WarehousesTransfersAlerts from '@/containers/WarehouseTransfers/Warehouse import BranchesAlerts from '@/containers/Preferences/Branches/BranchesAlerts'; import ProjectAlerts from '@/containers/Projects/containers/ProjectAlerts'; import TaxRatesAlerts from '@/containers/TaxRates/alerts'; +import { CashflowAlerts } from '../CashFlow/CashflowAlerts'; export default [ ...AccountsAlerts, @@ -41,7 +41,6 @@ export default [ ...CustomersAlerts, ...VendorsAlerts, ...ManualJournalsAlerts, - ...ExchangeRatesAlerts, ...ExpensesAlerts, ...AccountTransactionsAlerts, ...UsersAlerts, @@ -54,5 +53,6 @@ export default [ ...WarehousesTransfersAlerts, ...BranchesAlerts, ...ProjectAlerts, - ...TaxRatesAlerts + ...TaxRatesAlerts, + ...CashflowAlerts, ]; diff --git a/packages/webapp/src/containers/Authentication/Authentication.tsx b/packages/webapp/src/containers/Authentication/Authentication.tsx index 8d2887849..8b05570c4 100644 --- a/packages/webapp/src/containers/Authentication/Authentication.tsx +++ b/packages/webapp/src/containers/Authentication/Authentication.tsx @@ -8,9 +8,9 @@ import { TransitionGroup, CSSTransition } from 'react-transition-group'; import authenticationRoutes from '@/routes/authentication'; import { Icon, FormattedMessage as T } from '@/components'; import { useIsAuthenticated } from '@/hooks/state'; +import { AuthMetaBootProvider } from './AuthMetaBoot'; import '@/style/pages/Authentication/Auth.scss'; -import { AuthMetaBootProvider } from './AuthMetaBoot'; export function Authentication() { const to = { pathname: '/' }; diff --git a/packages/webapp/src/containers/Authentication/Login.tsx b/packages/webapp/src/containers/Authentication/Login.tsx index 9efbac5e9..0ac30e1bc 100644 --- a/packages/webapp/src/containers/Authentication/Login.tsx +++ b/packages/webapp/src/containers/Authentication/Login.tsx @@ -70,7 +70,7 @@ function LoginFooterLinks() { {!signupDisabled && ( - Don't have an account? Sign up + )} diff --git a/packages/webapp/src/containers/Authentication/Register.tsx b/packages/webapp/src/containers/Authentication/Register.tsx index 5a42bbf67..32225c850 100644 --- a/packages/webapp/src/containers/Authentication/Register.tsx +++ b/packages/webapp/src/containers/Authentication/Register.tsx @@ -87,7 +87,7 @@ function RegisterFooterLinks() { return ( - Return to Sign In + diff --git a/packages/webapp/src/containers/Authentication/ResetPassword.tsx b/packages/webapp/src/containers/Authentication/ResetPassword.tsx index 136d28174..afb43308b 100644 --- a/packages/webapp/src/containers/Authentication/ResetPassword.tsx +++ b/packages/webapp/src/containers/Authentication/ResetPassword.tsx @@ -5,7 +5,7 @@ import { Formik } from 'formik'; import { Intent, Position } from '@blueprintjs/core'; import { Link, useParams, useHistory } from 'react-router-dom'; -import { AppToaster } from '@/components'; +import { AppToaster, FormattedMessage as T } from '@/components'; import { useAuthResetPassword } from '@/hooks/query'; import AuthInsider from '@/containers/Authentication/AuthInsider'; @@ -86,11 +86,11 @@ function ResetPasswordFooterLinks() { {!signupDisabled && ( - Don't have an account? Sign up + )} - Return to Sign In + ); diff --git a/packages/webapp/src/containers/Authentication/SendResetPassword.tsx b/packages/webapp/src/containers/Authentication/SendResetPassword.tsx index c90f872c1..b8f24831c 100644 --- a/packages/webapp/src/containers/Authentication/SendResetPassword.tsx +++ b/packages/webapp/src/containers/Authentication/SendResetPassword.tsx @@ -5,7 +5,7 @@ import { Formik } from 'formik'; import { Link, useHistory } from 'react-router-dom'; import { Intent } from '@blueprintjs/core'; -import { AppToaster } from '@/components'; +import { AppToaster, FormattedMessage as T } from '@/components'; import { useAuthSendResetPassword } from '@/hooks/query'; import SendResetPasswordForm from './SendResetPasswordForm'; @@ -82,11 +82,11 @@ function SendResetPasswordFooterLinks() { {!signupDisabled && ( - Don't have an account? Sign up + )} - Return to Sign In + ); diff --git a/packages/webapp/src/containers/Authentication/SendResetPasswordForm.tsx b/packages/webapp/src/containers/Authentication/SendResetPasswordForm.tsx index 3f2718d59..a2f6e114b 100644 --- a/packages/webapp/src/containers/Authentication/SendResetPasswordForm.tsx +++ b/packages/webapp/src/containers/Authentication/SendResetPasswordForm.tsx @@ -14,8 +14,7 @@ export default function SendResetPasswordForm({ isSubmitting }) { return (
- Enter the email address associated with your account and we'll send you - a link to reset your password. + }> @@ -29,7 +28,7 @@ export default function SendResetPasswordForm({ isSubmitting }) { large={true} loading={isSubmitting} > - Reset Password + ); diff --git a/packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx b/packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx new file mode 100644 index 000000000..a33c400a2 --- /dev/null +++ b/packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx @@ -0,0 +1,100 @@ +// @ts-nocheck +import React, { useEffect } from 'react'; +import { + usePlaidLink, + PlaidLinkOnSuccessMetadata, + PlaidLinkOnExitMetadata, + PlaidLinkError, + PlaidLinkOptionsWithLinkToken, + PlaidLinkOnEventMetadata, + PlaidLinkStableEvent, +} from 'react-plaid-link'; +import { logEvent, logExit, logSuccess } from './_utils'; +import { usePlaidExchangeToken } from '@/hooks/query'; +import { useResetBankingPlaidToken } from '@/hooks/state/banking'; + +interface PlaidLaunchLinkProps { + token: string; + itemId?: number | null; + children?: React.ReactNode; +} + +/** + * Uses the usePlaidLink hook to manage the Plaid Link creation. + * See https://github.com/plaid/react-plaid-link for full usage instructions. + * The link token passed to usePlaidLink cannot be null. + * It must be generated outside of this component. In this sample app, the link token + * is generated in the link context in client/src/services/link.js. + * + * @param {PlaidLaunchLinkProps} props + * @returns {React.ReactNode} + */ +export function LaunchLink(props: PlaidLaunchLinkProps) { + const resetPlaidToken = useResetBankingPlaidToken(); + const { mutateAsync: exchangeAccessToken } = usePlaidExchangeToken(); + + // define onSuccess, onExit and onEvent functions as configs for Plaid Link creation + const onSuccess = async ( + publicToken: string, + metadata: PlaidLinkOnSuccessMetadata, + ) => { + // log and save metatdata + logSuccess(metadata); + if (props.itemId != null) { + // update mode: no need to exchange public token + // await setItemState(props.itemId, 'good'); + // deleteLinkToken(null, props.itemId); + // getItemById(props.itemId, true); + // regular link mode: exchange public token for access token + } else { + await exchangeAccessToken({ + public_token: publicToken, + institution_id: metadata.institution.institution_id, + }); + } + // resetError(); + resetPlaidToken(); + }; + + // Handle other error codes, see https://plaid.com/docs/errors/ + const onExit = async ( + error: PlaidLinkError | null, + metadata: PlaidLinkOnExitMetadata, + ) => { + // log and save error and metatdata + logExit(error, metadata, props.userId); + if (error != null) { + // setError(error.error_code, error.display_message || error.error_message); + } + resetPlaidToken(); + }; + + const onEvent = async ( + eventName: PlaidLinkStableEvent | string, + metadata: PlaidLinkOnEventMetadata, + ) => { + // handle errors in the event end-user does not exit with onExit function error enabled. + if (eventName === 'ERROR' && metadata.error_code != null) { + // setError(metadata.error_code, ' '); + } + logEvent(eventName, metadata); + }; + + const config: PlaidLinkOptionsWithLinkToken = { + onSuccess, + onExit, + onEvent, + token: props.token, + }; + + const { open, ready } = usePlaidLink(config); + + useEffect(() => { + // initiallizes Link automatically + if (ready) { + open(); + } + }, [ready, open, props.itemId, props.token]); + + return <>; +} diff --git a/packages/webapp/src/containers/Banking/Plaid/_utils.ts b/packages/webapp/src/containers/Banking/Plaid/_utils.ts new file mode 100644 index 000000000..4e662947c --- /dev/null +++ b/packages/webapp/src/containers/Banking/Plaid/_utils.ts @@ -0,0 +1,46 @@ +import { + PlaidLinkError, + PlaidLinkOnEventMetadata, + PlaidLinkOnExitMetadata, + PlaidLinkOnSuccessMetadata, + PlaidLinkStableEvent, +} from 'react-plaid-link'; + +export const logEvent = ( + eventName: PlaidLinkStableEvent | string, + metadata: + | PlaidLinkOnEventMetadata + | PlaidLinkOnSuccessMetadata + | PlaidLinkOnExitMetadata, + error?: PlaidLinkError | null, +) => { + console.log(`Link Event: ${eventName}`, metadata, error); +}; + +export const logSuccess = async ({ + institution, + accounts, + link_session_id, +}: PlaidLinkOnSuccessMetadata) => { + logEvent('onSuccess', { + institution, + accounts, + link_session_id, + }); +}; + +export const logExit = async ( + error: PlaidLinkError | null, + { institution, status, link_session_id, request_id }: PlaidLinkOnExitMetadata, +) => { + logEvent( + 'onExit', + { + institution, + status, + link_session_id, + request_id, + }, + error, + ); +}; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsAllBoot.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsAllBoot.tsx new file mode 100644 index 000000000..618a542fb --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsAllBoot.tsx @@ -0,0 +1,78 @@ +// @ts-nocheck +import React from 'react'; +import { flatten, map } from 'lodash'; +import { IntersectionObserver } from '@/components'; +import { useAccountTransactionsInfinity } from '@/hooks/query'; +import { useAccountTransactionsContext } from './AccountTransactionsProvider'; + +const AccountTransactionsAllBootContext = React.createContext(); + +function flattenInfinityPages(data) { + return flatten(map(data.pages, (page) => page.transactions)); +} + +interface AccountTransactionsAllPoviderProps { + children: React.ReactNode; +} + +/** + * Account transctions all provider. + */ +function AccountTransactionsAllProvider({ + children, +}: AccountTransactionsAllPoviderProps) { + const { accountId } = useAccountTransactionsContext(); + + // Fetch cashflow account transactions list + const { + data: cashflowTransactionsPages, + isFetching: isCashFlowTransactionsFetching, + isLoading: isCashFlowTransactionsLoading, + isSuccess: isCashflowTransactionsSuccess, + fetchNextPage: fetchNextTransactionsPage, + isFetchingNextPage: isCashflowTransactionsFetchingNextPage, + hasNextPage: hasCashflowTransactionsNextPgae, + } = useAccountTransactionsInfinity(accountId, { + page_size: 50, + account_id: accountId, + }); + // Memorized the cashflow account transactions. + const cashflowTransactions = React.useMemo( + () => + isCashflowTransactionsSuccess + ? flattenInfinityPages(cashflowTransactionsPages) + : [], + [cashflowTransactionsPages, isCashflowTransactionsSuccess], + ); + // Handle the observer ineraction. + const handleObserverInteract = React.useCallback(() => { + if (!isCashFlowTransactionsFetching && hasCashflowTransactionsNextPgae) { + fetchNextTransactionsPage(); + } + }, [ + isCashFlowTransactionsFetching, + hasCashflowTransactionsNextPgae, + fetchNextTransactionsPage, + ]); + // Provider payload. + const provider = { + cashflowTransactions, + isCashFlowTransactionsFetching, + isCashFlowTransactionsLoading, + }; + + return ( + + {children} + + + ); +} + +const useAccountTransactionsAllContext = () => + React.useContext(AccountTransactionsAllBootContext); + +export { AccountTransactionsAllProvider, useAccountTransactionsAllContext }; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx index cd189a933..2b7c9e79a 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx @@ -18,10 +18,10 @@ import withDrawerActions from '@/containers/Drawer/withDrawerActions'; import { useMemorizedColumnsWidths } from '@/hooks'; import { useAccountTransactionsColumns, ActionsMenu } from './components'; -import { useAccountTransactionsContext } from './AccountTransactionsProvider'; import { handleCashFlowTransactionType } from './utils'; import { compose } from '@/utils'; +import { useAccountTransactionsAllContext } from './AccountTransactionsAllBoot'; /** * Account transactions data table. @@ -41,7 +41,7 @@ function AccountTransactionsDataTable({ // Retrieve list context. const { cashflowTransactions, isCashFlowTransactionsLoading } = - useAccountTransactionsContext(); + useAccountTransactionsAllContext(); // Local storage memorizing columns widths. const [initialColumnsWidths, , handleColumnResizing] = @@ -51,11 +51,10 @@ function AccountTransactionsDataTable({ const handleDeleteTransaction = ({ reference_id }) => { openAlert('account-transaction-delete', { referenceId: reference_id }); }; - + // Handle view details action. const handleViewDetailCashflowTransaction = (referenceType) => { handleCashFlowTransactionType(referenceType, openDrawer); }; - // Handle cell click. const handleCellClick = (cell, event) => { const referenceType = cell.row.original; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDetailsBar.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDetailsBar.tsx index 02a03c3a8..90e10eae0 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDetailsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDetailsBar.tsx @@ -71,6 +71,31 @@ function AccountBalanceItem() { ); } +function AccountBankBalanceItem() { + const { currentAccount } = useAccountTransactionsContext(); + + return ( + + Balance in Bank Account + + {currentAccount.bank_balance_formatted} + + + ); +} + +function AccountNumberItem() { + const { currentAccount } = useAccountTransactionsContext(); + + if (!currentAccount.account_mask) return null; + + return ( + + Account Number: xxx{currentAccount.account_mask} + + ); +} + function AccountTransactionsDetailsBarSkeleton() { return ( @@ -88,7 +113,9 @@ function AccountTransactionsDetailsContent() { return ( + + ); } diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx new file mode 100644 index 000000000..c2d40059d --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx @@ -0,0 +1,50 @@ +// @ts-nocheck +import styled from 'styled-components'; +import { ContentTabs } from '@/components/ContentTabs/ContentTabs'; +import { useAccountTransactionsContext } from './AccountTransactionsProvider'; + +const AccountContentTabs = styled(ContentTabs)` + margin: 15px 15px 0 15px; +`; + +export function AccountTransactionsFilterTabs() { + const { filterTab, setFilterTab, currentAccount } = + useAccountTransactionsContext(); + + const handleChange = (value) => { + setFilterTab(value); + }; + + const hasUncategorizedTransx = Boolean( + currentAccount.uncategorized_transactions, + ); + + return ( + + + {hasUncategorizedTransx && ( + + + {currentAccount.uncategorized_transactions} + {' '} + Uncategorized Transactions + + } + description={'For Bank Statement'} + /> + )} + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx index 27fd17af2..43b4b706d 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx @@ -1,16 +1,19 @@ // @ts-nocheck -import React from 'react'; -import styled from 'styled-components'; +import React, { Suspense } from 'react'; +import { Spinner } from '@blueprintjs/core'; import '@/style/pages/CashFlow/AccountTransactions/List.scss'; import { DashboardPageContent } from '@/components'; import AccountTransactionsActionsBar from './AccountTransactionsActionsBar'; -import AccountTransactionsDataTable from './AccountTransactionsDataTable'; -import { AccountTransactionsProvider } from './AccountTransactionsProvider'; +import { + AccountTransactionsProvider, + useAccountTransactionsContext, +} from './AccountTransactionsProvider'; import { AccountTransactionsDetailsBar } from './AccountTransactionsDetailsBar'; import { AccountTransactionsProgressBar } from './components'; +import { AccountTransactionsFilterTabs } from './AccountTransactionsFilterTabs'; /** * Account transactions list. @@ -23,9 +26,11 @@ function AccountTransactionsList() { - - - + + + }> + + ); @@ -33,11 +38,20 @@ function AccountTransactionsList() { export default AccountTransactionsList; -const CashflowTransactionsTableCard = styled.div` - border: 2px solid #f0f0f0; - border-radius: 10px; - padding: 30px 18px; - margin: 30px 15px; - background: #fff; - flex: 0 1; -`; +const AccountsTransactionsAll = React.lazy( + () => import('./AccountsTransactionsAll'), +); + +const AccountsTransactionsUncategorized = React.lazy( + () => import('./AllTransactionsUncategorized'), +); + +function AccountTransactionsContent() { + const { filterTab } = useAccountTransactionsContext(); + + return filterTab === 'uncategorized' ? ( + + ) : ( + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx index 744863b87..1b3c98a29 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx @@ -1,20 +1,12 @@ // @ts-nocheck import React from 'react'; import { useParams } from 'react-router-dom'; -import { flatten, map } from 'lodash'; -import { IntersectionObserver, DashboardInsider } from '@/components'; -import { - useAccountTransactionsInfinity, - useCashflowAccounts, - useAccount, -} from '@/hooks/query'; +import { DashboardInsider } from '@/components'; +import { useCashflowAccounts, useAccount } from '@/hooks/query'; +import { useAppQueryString } from '@/hooks'; const AccountTransactionsContext = React.createContext(); -function flattenInfinityPages(data) { - return flatten(map(data.pages, (page) => page.transactions)); -} - /** * Account transctions provider. */ @@ -22,29 +14,12 @@ function AccountTransactionsProvider({ query, ...props }) { const { id } = useParams(); const accountId = parseInt(id, 10); - // Fetch cashflow account transactions list - const { - data: cashflowTransactionsPages, - isFetching: isCashFlowTransactionsFetching, - isLoading: isCashFlowTransactionsLoading, - isSuccess: isCashflowTransactionsSuccess, - fetchNextPage: fetchNextTransactionsPage, - isFetchingNextPage, - hasNextPage, - } = useAccountTransactionsInfinity(accountId, { - page_size: 50, - account_id: accountId, - }); - - // Memorized the cashflow account transactions. - const cashflowTransactions = React.useMemo( - () => - isCashflowTransactionsSuccess - ? flattenInfinityPages(cashflowTransactionsPages) - : [], - [cashflowTransactionsPages, isCashflowTransactionsSuccess], - ); + const [locationQuery, setLocationQuery] = useAppQueryString(); + const filterTab = locationQuery?.filter || 'all'; + const setFilterTab = (value: string) => { + setLocationQuery({ filter: value }); + }; // Fetch cashflow accounts. const { data: cashflowAccounts, @@ -53,40 +28,31 @@ function AccountTransactionsProvider({ query, ...props }) { } = useCashflowAccounts(query, { keepPreviousData: true }); // Retrieve specific account details. + const { data: currentAccount, isFetching: isCurrentAccountFetching, isLoading: isCurrentAccountLoading, } = useAccount(accountId, { keepPreviousData: true }); - // Handle the observer ineraction. - const handleObserverInteract = React.useCallback(() => { - if (!isFetchingNextPage && hasNextPage) { - fetchNextTransactionsPage(); - } - }, [isFetchingNextPage, hasNextPage, fetchNextTransactionsPage]); - // Provider payload. const provider = { accountId, - cashflowTransactions, cashflowAccounts, currentAccount, - isCashFlowTransactionsFetching, - isCashFlowTransactionsLoading, + isCashFlowAccountsFetching, isCashFlowAccountsLoading, isCurrentAccountFetching, isCurrentAccountLoading, + + filterTab, + setFilterTab, }; return ( - ); } diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizeFilter.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizeFilter.tsx new file mode 100644 index 000000000..b7ac12623 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizeFilter.tsx @@ -0,0 +1,36 @@ +// @ts-nocheck +import styled from 'styled-components'; +import { Tag } from '@blueprintjs/core'; + +const Root = styled.div` + display: flex; + flex-direction: row; + gap: 10px; + margin-bottom: 18px; +`; + +const FilterTag = styled(Tag)` + min-height: 26px; + + &.bp4-minimal:not([class*='bp4-intent-']) { + background: #fff; + border: 1px solid #e1e2e8; + + &.bp4-interactive:hover { + background-color: rgba(143, 153, 168, 0.05); + } + } +`; + +export function AccountTransactionsUncategorizeFilter() { + return ( + + + All (2) + + + Recognized (0) + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx new file mode 100644 index 000000000..781f9b9b1 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx @@ -0,0 +1,129 @@ +// @ts-nocheck +import React from 'react'; +import styled from 'styled-components'; + +import { + DataTable, + TableFastCell, + TableSkeletonRows, + TableSkeletonHeader, + TableVirtualizedListRows, + FormattedMessage as T, +} from '@/components'; +import { TABLES } from '@/constants/tables'; + +import withSettings from '@/containers/Settings/withSettings'; +import withDrawerActions from '@/containers/Drawer/withDrawerActions'; + +import { useMemorizedColumnsWidths } from '@/hooks'; +import { + ActionsMenu, + useAccountUncategorizedTransactionsColumns, +} from './components'; +import { useAccountUncategorizedTransactionsContext } from './AllTransactionsUncategorizedBoot'; + +import { compose } from '@/utils'; +import { DRAWERS } from '@/constants/drawers'; + +/** + * Account transactions data table. + */ +function AccountTransactionsDataTable({ + // #withSettings + cashflowTansactionsTableSize, + + // #withDrawerActions + openDrawer, +}) { + // Retrieve table columns. + const columns = useAccountUncategorizedTransactionsColumns(); + + // Retrieve list context. + const { uncategorizedTransactions, isUncategorizedTransactionsLoading } = + useAccountUncategorizedTransactionsContext(); + + // Local storage memorizing columns widths. + const [initialColumnsWidths, , handleColumnResizing] = + useMemorizedColumnsWidths(TABLES.UNCATEGORIZED_CASHFLOW_TRANSACTION); + + // Handle cell click. + const handleCellClick = (cell, event) => { + openDrawer(DRAWERS.CATEGORIZE_TRANSACTION, { + uncategorizedTransactionId: cell.row.original.id, + }); + }; + + return ( + } + className="table-constrant" + /> + ); +} + +export default compose( + withSettings(({ cashflowTransactionsSettings }) => ({ + cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize, + })), + withDrawerActions, +)(AccountTransactionsDataTable); + +const DashboardConstrantTable = styled(DataTable)` + .table { + .thead { + .th { + background: #fff; + } + } + + .tbody { + .tr:last-child .td { + border-bottom: 0; + } + } + } +`; + +const CashflowTransactionsTable = styled(DashboardConstrantTable)` + .table .tbody { + .tbody-inner .tr.no-results { + .td { + padding: 2rem 0; + font-size: 14px; + color: #888; + font-weight: 400; + border-bottom: 0; + } + } + + .tbody-inner { + .tr .td:not(:first-child) { + border-left: 1px solid #e6e6e6; + } + + .td-description { + color: #5f6b7c; + } + } + } +`; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx new file mode 100644 index 000000000..c598e4bdc --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx @@ -0,0 +1,34 @@ +// @ts-nocheck +import styled from 'styled-components'; + +import '@/style/pages/CashFlow/AccountTransactions/List.scss'; + +import AccountTransactionsDataTable from './AccountTransactionsDataTable'; +import { AccountTransactionsUncategorizeFilter } from './AccountTransactionsUncategorizeFilter'; +import { AccountTransactionsAllProvider } from './AccountTransactionsAllBoot'; + +const Box = styled.div` + margin: 30px 15px; +`; + +const CashflowTransactionsTableCard = styled.div` + border: 2px solid #f0f0f0; + border-radius: 10px; + padding: 30px 18px; + background: #fff; + flex: 0 1; +`; + +export default function AccountTransactionsAll() { + return ( + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx new file mode 100644 index 000000000..716712a0d --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx @@ -0,0 +1,31 @@ +// @ts-nocheck +import styled from 'styled-components'; + +import '@/style/pages/CashFlow/AccountTransactions/List.scss'; + +import AccountTransactionsUncategorizedTable from './AccountTransactionsUncategorizedTable'; +import { AccountUncategorizedTransactionsBoot } from './AllTransactionsUncategorizedBoot'; + +const Box = styled.div` + margin: 30px 15px; +`; + +const CashflowTransactionsTableCard = styled.div` + border: 2px solid #f0f0f0; + border-radius: 10px; + padding: 30px 18px; + background: #fff; + flex: 0 1; +`; + +export default function AllTransactionsUncategorized() { + return ( + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorizedBoot.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorizedBoot.tsx new file mode 100644 index 000000000..ce57832b3 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorizedBoot.tsx @@ -0,0 +1,78 @@ +// @ts-nocheck + +import React from 'react'; +import { flatten, map } from 'lodash'; +import { IntersectionObserver } from '@/components'; +import { useAccountUncategorizedTransactionsInfinity } from '@/hooks/query'; +import { useAccountTransactionsContext } from './AccountTransactionsProvider'; + +const AccountUncategorizedTransactionsContext = React.createContext(); + +function flattenInfinityPagesData(data) { + return flatten(map(data.pages, (page) => page.data)); +} + +/** + * Account uncategorized transctions provider. + */ +function AccountUncategorizedTransactionsBoot({ children }) { + const { accountId } = useAccountTransactionsContext(); + + // Fetches the uncategorized transactions. + const { + data: uncategorizedTransactionsPage, + isFetching: isUncategorizedTransactionFetching, + isLoading: isUncategorizedTransactionsLoading, + isSuccess: isUncategorizedTransactionsSuccess, + isFetchingNextPage: isUncategorizedTransactionFetchNextPage, + fetchNextPage: fetchNextUncategorizedTransactionsPage, + hasNextPage: hasUncategorizedTransactionsNextPage, + } = useAccountUncategorizedTransactionsInfinity(accountId, { + page_size: 50, + }); + // Memorized the cashflow account transactions. + const uncategorizedTransactions = React.useMemo( + () => + isUncategorizedTransactionsSuccess + ? flattenInfinityPagesData(uncategorizedTransactionsPage) + : [], + [uncategorizedTransactionsPage, isUncategorizedTransactionsSuccess], + ); + // Handle the observer ineraction. + const handleObserverInteract = React.useCallback(() => { + if ( + !isUncategorizedTransactionFetching && + hasUncategorizedTransactionsNextPage + ) { + fetchNextUncategorizedTransactionsPage(); + } + }, [ + isUncategorizedTransactionFetching, + hasUncategorizedTransactionsNextPage, + fetchNextUncategorizedTransactionsPage, + ]); + // Provider payload. + const provider = { + uncategorizedTransactions, + isUncategorizedTransactionFetching, + isUncategorizedTransactionsLoading, + }; + + return ( + + {children} + + + ); +} + +const useAccountUncategorizedTransactionsContext = () => + React.useContext(AccountUncategorizedTransactionsContext); + +export { + AccountUncategorizedTransactionsBoot, + useAccountUncategorizedTransactionsContext, +}; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx index 18e75161e..6dc334aa6 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx @@ -39,6 +39,7 @@ export function ActionsMenu({ ); } + /** * Retrieve account transctions table columns. */ @@ -131,7 +132,75 @@ export function useAccountTransactionsColumns() { * Account transactions progress bar. */ export function AccountTransactionsProgressBar() { - const { isCashFlowTransactionsFetching } = useAccountTransactionsContext(); + const { isCashFlowTransactionsFetching, isUncategorizedTransactionFetching } = + useAccountTransactionsContext(); - return isCashFlowTransactionsFetching ? : null; + return isCashFlowTransactionsFetching || + isUncategorizedTransactionFetching ? ( + + ) : null; +} + +/** + * Retrieve account uncategorized transctions table columns. + */ +export function useAccountUncategorizedTransactionsColumns() { + return React.useMemo( + () => [ + { + id: 'date', + Header: intl.get('date'), + accessor: 'formatted_date', + width: 40, + clickable: true, + textOverview: true, + }, + { + id: 'description', + Header: 'Description', + accessor: 'description', + width: 160, + textOverview: true, + clickable: true, + }, + { + id: 'payee', + Header: 'Payee', + accessor: 'payee', + width: 60, + clickable: true, + textOverview: true, + }, + { + id: 'reference_number', + Header: intl.get('reference_no'), + accessor: 'reference_number', + width: 50, + className: 'reference_number', + clickable: true, + textOverview: true, + }, + { + id: 'deposit', + Header: intl.get('cash_flow.label.deposit'), + accessor: 'formattet_deposit_amount', + width: 40, + className: 'deposit', + textOverview: true, + align: 'right', + clickable: true, + }, + { + id: 'withdrawal', + Header: intl.get('cash_flow.label.withdrawal'), + accessor: 'formatted_withdrawal_amount', + className: 'withdrawal', + width: 40, + textOverview: true, + align: 'right', + clickable: true, + }, + ], + [], + ); } diff --git a/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx b/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx index 4c5c2d4c8..66298dc8b 100644 --- a/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx @@ -1,5 +1,4 @@ // @ts-nocheck -import React from 'react'; import { Button, NavbarGroup, @@ -62,6 +61,10 @@ function CashFlowAccountsActionsBar({ const checked = event.target.checked; setCashflowAccountsTableState({ inactiveMode: checked }); }; + // Handle connect button click. + const handleConnectToBank = () => { + openDialog(DialogsName.ConnectBankCreditCard); + }; return ( @@ -107,6 +110,12 @@ function CashFlowAccountsActionsBar({ + + + + + + + ); +} + +export const CategorizeTransactionFormFooter = R.compose(withDrawerActions)( + CategorizeTransactionFormFooterRoot, +); + +const Root = styled.div` + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: #fff; +`; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOtherIncome.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOtherIncome.tsx new file mode 100644 index 000000000..2afc65f87 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOtherIncome.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionOtherIncome() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOwnerContribution.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOwnerContribution.tsx new file mode 100644 index 000000000..83b485c51 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOwnerContribution.tsx @@ -0,0 +1,68 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionOwnerContribution() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionTransferFrom.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionTransferFrom.tsx new file mode 100644 index 000000000..57f2a1911 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionTransferFrom.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionTransferFrom() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOtherExpense.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOtherExpense.tsx new file mode 100644 index 000000000..b85436e17 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOtherExpense.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionOtherExpense() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOwnerDrawings.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOwnerDrawings.tsx new file mode 100644 index 000000000..e39235fd9 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOwnerDrawings.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionOwnerDrawings() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionToAccount.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionToAccount.tsx new file mode 100644 index 000000000..4e4545e52 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionToAccount.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionToAccount() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts new file mode 100644 index 000000000..9fedc3678 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts @@ -0,0 +1,31 @@ +// @ts-nocheck +import { transformToForm, transfromToSnakeCase } from '@/utils'; + +// Default initial form values. +export const defaultInitialValues = { + amount: '', + date: '', + creditAccountId: '', + debitAccountId: '', + exchangeRate: '1', + transactionType: '', + referenceNo: '', + description: '', +}; + +export const transformToCategorizeForm = (uncategorizedTransaction) => { + const defaultValues = { + debitAccountId: uncategorizedTransaction.account_id, + transactionType: uncategorizedTransaction.is_deposit_transaction + ? 'other_income' + : 'other_expense', + amount: uncategorizedTransaction.amount, + date: uncategorizedTransaction.date, + }; + return transformToForm(defaultValues, defaultInitialValues); +}; + + +export const tranformToRequest = (formValues) => { + return transfromToSnakeCase(formValues); +}; \ No newline at end of file diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/index.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/index.ts new file mode 100644 index 000000000..bff919dc5 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/index.ts @@ -0,0 +1 @@ +export * from './CategorizeTransactionDrawer'; \ No newline at end of file diff --git a/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankDialog.tsx b/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankDialog.tsx new file mode 100644 index 000000000..2742d7957 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankDialog.tsx @@ -0,0 +1,32 @@ +// @ts-nocheck +import React from 'react'; +import { Dialog, DialogSuspense } from '@/components'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import { compose } from '@/utils'; + +const ConnectBankDialogBody = React.lazy( + () => import('./ConnectBankDialogBody'), +); + +/** + * Connect bank dialog. + */ +function ConnectBankDialogRoot({ dialogName, payload = {}, isOpen }) { + return ( + + + + + + ); +} + +export const ConnectBankDialog = compose(withDialogRedux())( + ConnectBankDialogRoot, +); diff --git a/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankDialogBody.tsx b/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankDialogBody.tsx new file mode 100644 index 000000000..83062d7a3 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankDialogBody.tsx @@ -0,0 +1,61 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { Form, Formik, FormikHelpers } from 'formik'; +import classNames from 'classnames'; +import { ConnectBankDialogContent } from './ConnectBankDialogContent'; +import { useGetPlaidLinkToken } from '@/hooks/query'; +import { useSetBankingPlaidToken } from '@/hooks/state/banking'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { CLASSES } from '@/constants'; +import { AppToaster } from '@/components'; +import { Intent } from '@blueprintjs/core'; +import { DialogsName } from '@/constants/dialogs'; + +const initialValues: ConnectBankDialogForm = { + serviceProvider: 'plaid', +}; + +interface ConnectBankDialogForm { + serviceProvider: 'plaid'; +} + +function ConnectBankDialogBodyRoot({ + // #withDialogActions + closeDialog, +}) { + const { mutateAsync: getPlaidLinkToken } = useGetPlaidLinkToken(); + const setPlaidId = useSetBankingPlaidToken(); + + // Handles the form submitting. + const handleSubmit = ( + values: ConnectBankDialogForm, + { setSubmitting }: FormikHelpers, + ) => { + setSubmitting(true); + getPlaidLinkToken() + .then((res) => { + setSubmitting(false); + closeDialog(DialogsName.ConnectBankCreditCard); + setPlaidId(res.data.link_token); + }) + .catch(() => { + setSubmitting(false); + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }); + }; + + return ( +
+ +
+ + +
+
+ ); +} + +export default R.compose(withDialogActions)(ConnectBankDialogBodyRoot); diff --git a/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankDialogContent.tsx b/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankDialogContent.tsx new file mode 100644 index 000000000..1bbc9c970 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankDialogContent.tsx @@ -0,0 +1,48 @@ +// @ts-nocheck +import styled from 'styled-components'; +import { Stack } from '@/components'; +import { TellerIcon } from '../Icons/TellerIcon'; +import { YodleeIcon } from '../Icons/YodleeIcon'; +import { PlaidIcon } from '../Icons/PlaidIcon'; +import { BankServiceCard } from './ConnectBankServiceCard'; + +const TopDesc = styled('p')` + margin-bottom: 20px; + color: #5f6b7c; +`; + +export function ConnectBankDialogContent() { + return ( +
+ + Connect your bank accounts and fetch the bank transactions using + one of our supported third-party service providers. + + + + } + > + Plaid gives the connection to 12,000 financial institutions across US, UK and Canada. + + + } + disabled + > + Connect instantly with more than 5,000 financial institutions across US. + + + } + disabled + > + Connect instantly with a global network of financial institutions. + + +
+ ); +} diff --git a/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankServiceCard.tsx b/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankServiceCard.tsx new file mode 100644 index 000000000..72b0852a6 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankServiceCard.tsx @@ -0,0 +1,72 @@ +import styled from 'styled-components'; +import { Group } from '@/components'; + +const BankServiceIcon = styled('div')` + height: 40px; + width: 40px; + border: 1px solid #c8cad0; + border-radius: 3px; + display: flex; + + svg { + margin: auto; + } +`; +const BankServiceContent = styled(`div`)` + flex: 1 0; +`; +const BankServiceCardRoot = styled('button')` + border-radius: 3px; + border: 1px solid #c8cad0; + transition: all 0.1s ease-in-out; + background: transparent; + text-align: inherit; + padding: 14px; + + &:not(:disabled) { + cursor: pointer; + } + &:hover:not(:disabled) { + border-color: #0153cc; + } + &:disabled { + background: #f9fdff; + } +`; +const BankServiceTitle = styled(`h3`)` + font-weight: 600; + font-size: 14px; + color: #2d333d; +`; +const BankServiceDesc = styled('p')` + margin-top: 4px; + margin-bottom: 6px; + font-size: 13px; + color: #738091; +`; + +interface BankServiceCardProps { + title: string; + children: React.ReactNode; + disabled?: boolean; + icon: React.ReactNode; +} + +export function BankServiceCard({ + title, + children, + icon, + disabled, +}: BankServiceCardProps) { + return ( + + + {icon} + + {title} + {children} + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/ConnectBankDialog/index.tsx b/packages/webapp/src/containers/CashFlow/ConnectBankDialog/index.tsx new file mode 100644 index 000000000..2267439d5 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/ConnectBankDialog/index.tsx @@ -0,0 +1 @@ +export * from './ConnectBankDialog'; diff --git a/packages/webapp/src/containers/CashFlow/Icons/PlaidIcon.tsx b/packages/webapp/src/containers/CashFlow/Icons/PlaidIcon.tsx new file mode 100644 index 000000000..7a9f5ef62 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/Icons/PlaidIcon.tsx @@ -0,0 +1,17 @@ + +export const PlaidIcon = (props: any) => ( + + + +); diff --git a/packages/webapp/src/containers/CashFlow/Icons/TellerIcon.tsx b/packages/webapp/src/containers/CashFlow/Icons/TellerIcon.tsx new file mode 100644 index 000000000..74ec99dd0 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/Icons/TellerIcon.tsx @@ -0,0 +1,42 @@ +export const TellerIcon = () => ( + + + + + + + + + + + + + + +); diff --git a/packages/webapp/src/containers/CashFlow/Icons/YodleeIcon.tsx b/packages/webapp/src/containers/CashFlow/Icons/YodleeIcon.tsx new file mode 100644 index 000000000..3016cd729 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/Icons/YodleeIcon.tsx @@ -0,0 +1,45 @@ +export const YodleeIcon = (props: any) => ( + + + + + + + + + + + +); diff --git a/packages/webapp/src/containers/CashFlow/MoneyInDialog/MoneyInDialogContent.tsx b/packages/webapp/src/containers/CashFlow/MoneyInDialog/MoneyInDialogContent.tsx index 690d720ef..866d19194 100644 --- a/packages/webapp/src/containers/CashFlow/MoneyInDialog/MoneyInDialogContent.tsx +++ b/packages/webapp/src/containers/CashFlow/MoneyInDialog/MoneyInDialogContent.tsx @@ -1,5 +1,4 @@ // @ts-nocheck -import React from 'react'; import { MoneyInDialogProvider } from './MoneyInDialogProvider'; import MoneyInForm from './MoneyInForm'; diff --git a/packages/webapp/src/containers/CashFlow/MoneyInDialog/MoneyInForm.tsx b/packages/webapp/src/containers/CashFlow/MoneyInDialog/MoneyInForm.tsx index 9bdc1f9ed..12f5dc7dd 100644 --- a/packages/webapp/src/containers/CashFlow/MoneyInDialog/MoneyInForm.tsx +++ b/packages/webapp/src/containers/CashFlow/MoneyInDialog/MoneyInForm.tsx @@ -72,7 +72,7 @@ function MoneyInForm({ }; // Handles the form submit. - const handleFormSubmit = (values, { setSubmitting, setErrors }) => { + const handleFormSubmit = (values, { setSubmitting }) => { const form = { ...omit(values, ['currency_code']), publish: true, diff --git a/packages/webapp/src/containers/CashFlow/MoneyOutDialog/MoneyOutForm.tsx b/packages/webapp/src/containers/CashFlow/MoneyOutDialog/MoneyOutForm.tsx index 8f48e07dd..ab21384aa 100644 --- a/packages/webapp/src/containers/CashFlow/MoneyOutDialog/MoneyOutForm.tsx +++ b/packages/webapp/src/containers/CashFlow/MoneyOutDialog/MoneyOutForm.tsx @@ -51,7 +51,6 @@ function MoneyOutForm({ accountId, accountType, createCashflowTransactionMutate, - submitPayload, } = useMoneyOutDialogContext(); // transaction number. diff --git a/packages/webapp/src/containers/CashFlow/UncategorizeTransactionAlert/UncategorizeTransactionAlert.tsx b/packages/webapp/src/containers/CashFlow/UncategorizeTransactionAlert/UncategorizeTransactionAlert.tsx new file mode 100644 index 000000000..0ef15fabe --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/UncategorizeTransactionAlert/UncategorizeTransactionAlert.tsx @@ -0,0 +1,83 @@ +// @ts-nocheck +import React from 'react'; +import { Intent, Alert } from '@blueprintjs/core'; +import { FormattedMessage as T } from '@/components'; +import { AppToaster } from '@/components'; + +import withAlertStoreConnect from '@/containers/Alert/withAlertStoreConnect'; +import withAlertActions from '@/containers/Alert/withAlertActions'; +import withDrawerActions from '@/containers/Drawer/withDrawerActions'; + +import { useUncategorizeTransaction } from '@/hooks/query'; +import { compose } from '@/utils'; +import { DRAWERS } from '@/constants/drawers'; + +/** + * Project delete alert. + */ +function UncategorizeTransactionAlert({ + name, + + // #withAlertStoreConnect + isOpen, + payload: { uncategorizedTransactionId }, + + // #withAlertActions + closeAlert, + + // #withDrawerActions + closeDrawer, +}) { + const { mutateAsync: uncategorizeTransaction, isLoading } = + useUncategorizeTransaction(); + + // handle cancel delete project alert. + const handleCancelDeleteAlert = () => { + closeAlert(name); + }; + + // handleConfirm delete project + const handleConfirmBtnClick = () => { + uncategorizeTransaction(uncategorizedTransactionId) + .then(() => { + AppToaster.show({ + message: 'The transaction has uncategorized successfully.', + intent: Intent.SUCCESS, + }); + closeAlert(name); + closeDrawer(DRAWERS.CASHFLOW_TRNASACTION_DETAILS); + }) + .catch( + ({ + response: { + data: { errors }, + }, + }) => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }, + ); + }; + + return ( + } + confirmButtonText={'Uncategorize'} + intent={Intent.WARNING} + isOpen={isOpen} + onCancel={handleCancelDeleteAlert} + onConfirm={handleConfirmBtnClick} + loading={isLoading} + > +

Are you sure want to uncategorize the transaction?

+
+ ); +} + +export default compose( + withAlertStoreConnect(), + withAlertActions, + withDrawerActions, +)(UncategorizeTransactionAlert); diff --git a/packages/webapp/src/containers/CashFlow/UncategorizeTransactionAlert/index.ts b/packages/webapp/src/containers/CashFlow/UncategorizeTransactionAlert/index.ts new file mode 100644 index 000000000..41a2b5dc3 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/UncategorizeTransactionAlert/index.ts @@ -0,0 +1 @@ +export * from './UncategorizeTransactionAlert'; \ No newline at end of file diff --git a/packages/webapp/src/containers/CashFlow/_components.tsx b/packages/webapp/src/containers/CashFlow/_components.tsx index 0195b298f..26e08a1b9 100644 --- a/packages/webapp/src/containers/CashFlow/_components.tsx +++ b/packages/webapp/src/containers/CashFlow/_components.tsx @@ -14,6 +14,7 @@ import { useUpdateEffect } from '@/hooks'; import withSettings from '@/containers/Settings/withSettings'; import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { transactionNumber } from '@/utils'; /** * Syncs cashflow auto-increment settings to the form once update. diff --git a/packages/webapp/src/containers/Customers/CustomerForm/CustomerForm.schema.tsx b/packages/webapp/src/containers/Customers/CustomerForm/CustomerForm.schema.tsx index f93810ac3..8cdb5d308 100644 --- a/packages/webapp/src/containers/Customers/CustomerForm/CustomerForm.schema.tsx +++ b/packages/webapp/src/containers/Customers/CustomerForm/CustomerForm.schema.tsx @@ -29,7 +29,7 @@ const Schema = Yup.object().shape({ billing_address_2: Yup.string().trim(), billing_address_city: Yup.string().trim(), billing_address_state: Yup.string().trim(), - billing_address_postcode: Yup.number().nullable(), + billing_address_postcode: Yup.string().nullable(), billing_address_phone: Yup.number(), shipping_address_country: Yup.string().trim(), @@ -37,7 +37,7 @@ const Schema = Yup.object().shape({ shipping_address_2: Yup.string().trim(), shipping_address_city: Yup.string().trim(), shipping_address_state: Yup.string().trim(), - shipping_address_postcode: Yup.number().nullable(), + shipping_address_postcode: Yup.string().nullable(), shipping_address_phone: Yup.number(), opening_balance: Yup.number().nullable(), diff --git a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateForm.schema.tsx b/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateForm.schema.tsx deleted file mode 100644 index 7ff0ff63d..000000000 --- a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateForm.schema.tsx +++ /dev/null @@ -1,19 +0,0 @@ -// @ts-nocheck -import * as Yup from 'yup'; -import intl from 'react-intl-universal'; -import { DATATYPES_LENGTH } from '@/constants/dataTypes'; - -const Schema = Yup.object().shape({ - exchange_rate: Yup.number() - .required() - .label(intl.get('exchange_rate_')), - currency_code: Yup.string() - .max(3) - .required(intl.get('currency_code_')), - date: Yup.date() - .required() - .label(intl.get('date')), -}); - -export const CreateExchangeRateFormSchema = Schema; -export const EditExchangeRateFormSchema = Schema; diff --git a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateForm.tsx b/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateForm.tsx deleted file mode 100644 index b0cb40518..000000000 --- a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateForm.tsx +++ /dev/null @@ -1,114 +0,0 @@ -// @ts-nocheck -import React, { useMemo } from 'react'; -import intl from 'react-intl-universal'; -import moment from 'moment'; -import { Intent } from '@blueprintjs/core'; -import { Formik } from 'formik'; -import { AppToaster } from '@/components'; -import { - CreateExchangeRateFormSchema, - EditExchangeRateFormSchema, -} from './ExchangeRateForm.schema'; -import ExchangeRateFormContent from './ExchangeRateFormContent'; -import { useExchangeRateFromContext } from './ExchangeRateFormProvider'; -import withDialogActions from '@/containers/Dialog/withDialogActions'; - -import { compose, transformToForm } from '@/utils'; - -const defaultInitialValues = { - exchange_rate: '', - currency_code: '', - date: moment(new Date()).format('YYYY-MM-DD'), -}; - -/** - * Exchange rate form. - */ -function ExchangeRateForm({ - // #withDialogActions - closeDialog, -}) { - const { - createExchangeRateMutate, - editExchangeRateMutate, - isNewMode, - dialogName, - exchangeRate, - } = useExchangeRateFromContext(); - - // Form validation schema in create and edit mode. - const validationSchema = isNewMode - ? CreateExchangeRateFormSchema - : EditExchangeRateFormSchema; - const initialValues = useMemo( - () => ({ - ...defaultInitialValues, - ...transformToForm(exchangeRate, defaultInitialValues), - }), - [], - ); - - // Transformers response errors. - const transformErrors = (errors, { setErrors }) => { - if ( - errors.find((error) => error.type === 'EXCHANGE.RATE.DATE.PERIOD.DEFINED') - ) { - setErrors({ - exchange_rate: intl.get( - 'there_is_exchange_rate_in_this_date_with_the_same_currency', - ), - }); - } - }; - - // Handle the form submit. - const handleFormSubmit = (values, { setSubmitting, setErrors }) => { - setSubmitting(true); - - // Handle close the dialog after success response. - const afterSubmit = () => { - closeDialog(dialogName); - }; - const onSuccess = ({ response }) => { - AppToaster.show({ - message: intl.get( - !isNewMode - ? 'the_exchange_rate_has_been_edited_successfully' - : 'the_exchange_rate_has_been_created_successfully', - ), - intent: Intent.SUCCESS, - }); - afterSubmit(response); - }; - // Handle the response error. - const onError = (error) => { - const { - response: { - data: { errors }, - }, - } = error; - - transformErrors(errors, { setErrors }); - setSubmitting(false); - }; - if (isNewMode) { - createExchangeRateMutate(values).then(onSuccess).catch(onError); - } else { - editExchangeRateMutate([exchangeRate.id, values]) - .then(onSuccess) - .catch(onError); - } - }; - - return ( - - - - ); -} - -export default compose(withDialogActions)(ExchangeRateForm); diff --git a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormContent.tsx b/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormContent.tsx deleted file mode 100644 index 07ecc1ebc..000000000 --- a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormContent.tsx +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-nocheck -import React from 'react'; -import { Form } from 'formik'; -import ExchangeRateFormFields from './ExchangeRateFormFields'; -import ExchangeRateFormFooter from './ExchangeRateFormFooter'; - -export default function ExchangeRateFormContent() { - return ( -
- - - - ); -} diff --git a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormDialogContent.tsx b/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormDialogContent.tsx deleted file mode 100644 index 2cad6e0c3..000000000 --- a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormDialogContent.tsx +++ /dev/null @@ -1,27 +0,0 @@ -// @ts-nocheck -import React from 'react'; - -import ExchangeRateForm from './ExchangeRateForm'; -import { ExchangeRateFormProvider } from './ExchangeRateFormProvider'; - -import '@/style/pages/ExchangeRate/ExchangeRateDialog.scss'; - -/** - * Exchange rate form content. - */ -export default function ExchangeRateFormDialogContent({ - // #ownProp - action, - exchangeRateId, - dialogName, -}) { - return ( - - - - ); -} diff --git a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormFields.tsx b/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormFields.tsx deleted file mode 100644 index 58c3eb262..000000000 --- a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormFields.tsx +++ /dev/null @@ -1,89 +0,0 @@ -// @ts-nocheck -import React from 'react'; -import { Classes, FormGroup, InputGroup, Position } from '@blueprintjs/core'; -import { FastField } from 'formik'; -import { DateInput } from '@blueprintjs/datetime'; -import { FormattedMessage as T } from '@/components'; -import classNames from 'classnames'; -import { - momentFormatter, - tansformDateValue, - handleDateChange, - inputIntent, -} from '@/utils'; -import { - ErrorMessage, - FieldRequiredHint, - CurrencySelectList, -} from '@/components'; -import { useExchangeRateFromContext } from './ExchangeRateFormProvider'; - - -export default function ExchangeRateFormFields() { - const { action, currencies } = useExchangeRateFromContext(); - - return ( -
- {/* ----------- Date ----------- */} - - {({ form, field: { value }, meta: { error, touched } }) => ( - } - labelInfo={FieldRequiredHint} - className={classNames('form-group--select-list', Classes.FILL)} - intent={inputIntent({ error, touched })} - helperText={} - inline={true} - > - { - form.setFieldValue('date', formattedDate); - })} - popoverProps={{ position: Position.BOTTOM, minimal: true }} - disabled={action === 'edit'} - /> - - )} - - {/* ----------- Currency Code ----------- */} - - {({ form, field: { value }, meta: { error, touched } }) => ( - } - labelInfo={} - className={classNames('form-group--currency', Classes.FILL)} - intent={inputIntent({ error, touched })} - helperText={} - inline={true} - > - { - form.setFieldValue('currency_code', currency_code); - }} - disabled={action === 'edit'} - /> - - )} - - - {/*------------ Exchange Rate -----------*/} - - {({ form, field, meta: { error, touched } }) => ( - } - labelInfo={} - intent={inputIntent({ error, touched })} - helperText={} - inline={true} - > - - - )} - -
- ); -} diff --git a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormFooter.tsx b/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormFooter.tsx deleted file mode 100644 index ef66f7674..000000000 --- a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormFooter.tsx +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-nocheck -import React from 'react'; -import { useFormikContext } from 'formik'; - -import { Button, Classes, Intent } from '@blueprintjs/core'; -import { FormattedMessage as T } from '@/components'; -import { useExchangeRateFromContext } from './ExchangeRateFormProvider'; -import withDialogActions from '@/containers/Dialog/withDialogActions'; -import { compose } from '@/utils'; - -function ExchangeRateFormFooter({ - // #withDialogActions - closeDialog, -}) { - const { isSubmitting } = useFormikContext(); - const { dialogName, action } = useExchangeRateFromContext(); - - const handleClose = () => { - closeDialog(dialogName); - }; - - return ( -
-
- - -
-
- ); -} - -export default compose(withDialogActions)(ExchangeRateFormFooter); diff --git a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormProvider.tsx b/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormProvider.tsx deleted file mode 100644 index 90bdf8e96..000000000 --- a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormProvider.tsx +++ /dev/null @@ -1,53 +0,0 @@ -// @ts-nocheck -import React, { createContext, useContext } from 'react'; -import { - useCreateExchangeRate, - useEdiExchangeRate, - useCurrencies, - useExchangeRates, -} from '@/hooks/query'; -import { DialogContent } from '@/components'; - -const ExchangeRateFormContext = createContext(); - -/** - * Exchange rate Form page provider. - */ -function ExchangeRateFormProvider({ - exchangeRate, - action, - dialogName, - ...props -}) { - // Create and edit exchange rate mutations. - const { mutateAsync: createExchangeRateMutate } = useCreateExchangeRate(); - const { mutateAsync: editExchangeRateMutate } = useEdiExchangeRate(); - - // Load Currencies list. - const { data: currencies, isFetching: isCurrenciesLoading } = useCurrencies(); - const { isFetching: isExchangeRatesLoading } = useExchangeRates(); - - const isNewMode = !exchangeRate; - - // Provider state. - const provider = { - createExchangeRateMutate, - editExchangeRateMutate, - dialogName, - exchangeRate, - action, - currencies, - isExchangeRatesLoading, - isNewMode, - }; - - return ( - - - - ); -} - -const useExchangeRateFromContext = () => useContext(ExchangeRateFormContext); - -export { ExchangeRateFormProvider, useExchangeRateFromContext }; diff --git a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/index.tsx b/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/index.tsx deleted file mode 100644 index 3bbc71954..000000000 --- a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/index.tsx +++ /dev/null @@ -1,45 +0,0 @@ -// @ts-nocheck -import React, { lazy } from 'react'; -import { Dialog, DialogSuspense, FormattedMessage as T } from '@/components'; -import withDialogRedux from '@/components/DialogReduxConnect'; -import { compose } from '@/utils'; - -const ExchangeRateFormDialogContent = lazy( - () => import('./ExchangeRateFormDialogContent'), -); - -/** - * Exchange rate form dialog. - */ -function ExchangeRateFormDialog({ - dialogName, - payload = { action: '', id: null, exchangeRate: '' }, - isOpen, -}) { - return ( - - ) : ( - - ) - } - className={'dialog--exchangeRate-form'} - isOpen={isOpen} - autoFocus={true} - canEscapeKeyClose={true} - > - - - - - ); -} - -export default compose(withDialogRedux())(ExchangeRateFormDialog); diff --git a/packages/webapp/src/containers/Dialogs/TransactionNumberDialog/TransactionNumberDialogContent.tsx b/packages/webapp/src/containers/Dialogs/TransactionNumberDialog/TransactionNumberDialogContent.tsx index 163857416..ab6c4b341 100644 --- a/packages/webapp/src/containers/Dialogs/TransactionNumberDialog/TransactionNumberDialogContent.tsx +++ b/packages/webapp/src/containers/Dialogs/TransactionNumberDialog/TransactionNumberDialogContent.tsx @@ -9,11 +9,11 @@ import ReferenceNumberForm from '@/containers/JournalNumber/ReferenceNumberForm' import withDialogActions from '@/containers/Dialog/withDialogActions'; import withSettings from '@/containers/Settings/withSettings'; import withSettingsActions from '@/containers/Settings/withSettingsActions'; -import { compose } from '@/utils'; import { transformFormToSettings, transformSettingsToForm, } from '@/containers/JournalNumber/utils'; +import { compose } from '@/utils'; /** * Transaction number dialog content. diff --git a/packages/webapp/src/containers/Drawers/CashflowTransactionDetailDrawer/CashflowTransactionDrawerActionBar.tsx b/packages/webapp/src/containers/Drawers/CashflowTransactionDetailDrawer/CashflowTransactionDrawerActionBar.tsx index ade249bae..2e0baa665 100644 --- a/packages/webapp/src/containers/Drawers/CashflowTransactionDetailDrawer/CashflowTransactionDrawerActionBar.tsx +++ b/packages/webapp/src/containers/Drawers/CashflowTransactionDetailDrawer/CashflowTransactionDrawerActionBar.tsx @@ -1,11 +1,18 @@ // @ts-nocheck import React from 'react'; -import { Button, Classes, NavbarGroup, Intent } from '@blueprintjs/core'; +import { + Button, + Classes, + NavbarGroup, + Intent, + NavbarDivider, +} from '@blueprintjs/core'; import { Can, FormattedMessage as T, DrawerActionsBar, Icon, + If, } from '@/components'; import withAlertsActions from '@/containers/Alert/withAlertActions'; import { useCashflowTransactionDrawerContext } from './CashflowTransactionDrawerProvider'; @@ -19,13 +26,22 @@ function CashflowTransactionDrawerActionBar({ // #withAlertsDialog openAlert, }) { - const { referenceId } = useCashflowTransactionDrawerContext(); + const { referenceId, cashflowTransaction } = + useCashflowTransactionDrawerContext(); // Handle cashflow transaction delete action. const handleDeleteCashflowTransaction = () => { openAlert('account-transaction-delete', { referenceId }); }; + // Handles the uncategorize button click. + const handleUncategorizeBtnClick = () => { + openAlert('cashflow-tranaction-uncategorize', { + uncategorizedTransactionId: + cashflowTransaction.uncategorized_transaction_id, + }); + }; + return ( @@ -37,6 +53,14 @@ function CashflowTransactionDrawerActionBar({ intent={Intent.DANGER} onClick={handleDeleteCashflowTransaction} /> + + + + + + + ); +} + +const CardFooterActions = styled.div` + padding-top: 16px; + border-top: 1px solid #e0e7ea; + margin-top: 30px; + + .bp4-button { + min-width: 70px; + + + .bp4-button { + margin-left: 10px; + } + } +`; diff --git a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormBoot.tsx b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormBoot.tsx new file mode 100644 index 000000000..0eb34f3d4 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormBoot.tsx @@ -0,0 +1,55 @@ +// @ts-nocheck +import React from 'react'; +import styled from 'styled-components'; +import classNames from 'classnames'; +import { CLASSES } from '@/constants/classes'; +import { useSettings } from '@/hooks/query'; +import PreferencesPageLoader from '../PreferencesPageLoader'; +import { Card } from '@/components'; + +const PreferencesCreditNotesFormContext = React.createContext(); + +function PreferencesCreditNotesBoot({ ...props }) { + // Fetches organization settings. + const { isLoading: isSettingsLoading } = useSettings(); + + // Provider state. + const provider = { + isSettingsLoading, + }; + // Detarmines whether if any query is loading. + const isLoading = isSettingsLoading; + + return ( +
+ + {isLoading ? ( + + ) : ( + + )} + +
+ ); +} + +const PreferencesCreditNotesCard = styled(Card)` + padding: 25px; + + .bp4-form-group { + max-width: 600px; + } +`; + +const usePreferencesCreditNotesFormContext = () => + React.useContext(PreferencesCreditNotesFormContext); + +export { PreferencesCreditNotesBoot, usePreferencesCreditNotesFormContext }; diff --git a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormPage.tsx b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormPage.tsx new file mode 100644 index 000000000..4fc3956c8 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormPage.tsx @@ -0,0 +1,82 @@ +// @ts-nocheck +import { useEffect } from 'react'; +import intl from 'react-intl-universal'; +import { Formik } from 'formik'; +import * as R from 'ramda'; +import { Intent } from '@blueprintjs/core'; + +import { AppToaster } from '@/components'; +import { PreferencesCreditNotesFormSchema } from './PreferencesCreditNotesForm.schema'; +import { PreferencesCreditNotesForm } from './PreferencesCreditNotesForm'; +import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; + +import { compose, transformToForm, transfromToSnakeCase } from '@/utils'; +import withSettings from '@/containers/Settings/withSettings'; +import { transferObjectOptionsToArray } from '../Accountant/utils'; +import { useSaveSettings } from '@/hooks/query'; + +const defaultValues = { + termsConditions: '', + customerNotes: '', +}; + +/** + * Preferences - Credit Notes. + */ +function PreferencesCreditNotesFormPageRoot({ + // #withDashboardActions + changePreferencesPageTitle, + + // #withSettings + creditNoteSettings, +}) { + // Save settings. + const { mutateAsync: saveSettingMutate } = useSaveSettings(); + + useEffect(() => { + changePreferencesPageTitle(intl.get('preferences.creditNotes')); + }, [changePreferencesPageTitle]); + + // Initial values. + const initialValues = { + ...defaultValues, + ...transformToForm(creditNoteSettings, defaultValues), + }; + // Handle the form submit. + const handleFormSubmit = (values, { setSubmitting }) => { + const options = R.compose( + transferObjectOptionsToArray, + transfromToSnakeCase, + )({ creditNote: { ...values } }); + + // Handle request success. + const onSuccess = () => { + AppToaster.show({ + message: intl.get('preferences.credit_notes.success_message'), + intent: Intent.SUCCESS, + }); + setSubmitting(false); + }; + // Handle request error. + const onError = () => { + setSubmitting(false); + }; + saveSettingMutate({ options }).then(onSuccess).catch(onError); + }; + + return ( + + ); +} + +export const PreferencesCreditNotesFormPage = compose( + withDashboardActions, + withSettings(({ creditNoteSettings }) => ({ + creditNoteSettings: creditNoteSettings, + })), +)(PreferencesCreditNotesFormPageRoot); diff --git a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimates.tsx b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimates.tsx new file mode 100644 index 000000000..d7a8b484d --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimates.tsx @@ -0,0 +1,14 @@ +// @ts-nocheck +import { PreferencesEstimatesBoot } from './PreferencesEstimatesFormBoot'; +import { PreferencesEstimatesFormPage } from './PreferencesEstimatesFormPage'; + +/** + * Estimates preferences. + */ +export function PreferencesEstimates() { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.schema.ts b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.schema.ts new file mode 100644 index 000000000..b6cf3eab6 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.schema.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import * as Yup from 'yup'; + +const Schema = Yup.object().shape({ + termsConditions: Yup.string().optional(), + customerNotes: Yup.string().optional(), +}); + +export const PreferencesEstimatesFormSchema = Schema; diff --git a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.tsx b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.tsx new file mode 100644 index 000000000..7e17acc10 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.tsx @@ -0,0 +1,74 @@ +// @ts-nocheck +import styled from 'styled-components'; +import { Form } from 'formik'; +import { Button, Intent } from '@blueprintjs/core'; +import { useHistory } from 'react-router-dom'; + +import { FormattedMessage as T, FFormGroup, FTextArea } from '@/components'; + +/** + * Preferences estimates form. + */ +export function PreferencesEstimatesForm({ isSubmitting }) { + const history = useHistory(); + + // Handle close click. + const handleCloseClick = () => { + history.go(-1); + }; + + return ( +
+ {/* ---------- Customer Notes ---------- */} + } + fastField={true} + > + + + + {/* ---------- Terms & Conditions ---------- */} + } + fastField={true} + > + + + + + + + +
+ ); +} + +const CardFooterActions = styled.div` + padding-top: 16px; + border-top: 1px solid #e0e7ea; + margin-top: 30px; + + .bp4-button { + min-width: 70px; + + + .bp4-button { + margin-left: 10px; + } + } +`; diff --git a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormBoot.tsx b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormBoot.tsx new file mode 100644 index 000000000..d39d3c817 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormBoot.tsx @@ -0,0 +1,55 @@ +// @ts-nocheck +import React from 'react'; +import classNames from 'classnames'; +import { CLASSES } from '@/constants/classes'; +import { useSettings } from '@/hooks/query'; +import PreferencesPageLoader from '../PreferencesPageLoader'; +import styled from 'styled-components'; +import { Card } from '@/components'; + +const PreferencesEstimatesFormContext = React.createContext(); + +function PreferencesEstimatesBoot({ ...props }) { + // Fetches organization settings. + const { isLoading: isSettingsLoading } = useSettings(); + + // Provider state. + const provider = { + isSettingsLoading, + }; + // Detarmines whether if any query is loading. + const isLoading = isSettingsLoading; + + return ( +
+ + {isLoading ? ( + + ) : ( + + )} + +
+ ); +} + +const usePreferencesEstimatesFormContext = () => + React.useContext(PreferencesEstimatesFormContext); + +const PreferencesEstimatesCard = styled(Card)` + padding: 25px; + + .bp4-form-group { + max-width: 600px; + } +`; + +export { PreferencesEstimatesBoot, usePreferencesEstimatesFormContext }; diff --git a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormPage.tsx b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormPage.tsx new file mode 100644 index 000000000..39d849ed3 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormPage.tsx @@ -0,0 +1,82 @@ +// @ts-nocheck +import React, { useEffect } from 'react'; +import intl from 'react-intl-universal'; +import { Formik } from 'formik'; +import { Intent } from '@blueprintjs/core'; +import * as R from 'ramda'; + +import { AppToaster } from '@/components'; +import { PreferencesEstimatesFormSchema } from './PreferencesEstimatesForm.schema'; +import { PreferencesEstimatesForm } from './PreferencesEstimatesForm'; +import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; +import withSettings from '@/containers/Settings/withSettings'; + +import { transferObjectOptionsToArray } from '../Accountant/utils'; +import { compose, transformToForm, transfromToSnakeCase } from '@/utils'; +import { useSaveSettings } from '@/hooks/query'; + +const defaultValues = { + termsConditions: '', + customerNotes: '', +}; + +/** + * Preferences estimates form. + */ +function PreferencesEstimatesFormPageRoot({ + // #withDashboardActions + changePreferencesPageTitle, + + // #withSettings + estimatesSettings, +}) { + // Save Organization Settings. + const { mutateAsync: saveSettingMutate } = useSaveSettings(); + + useEffect(() => { + changePreferencesPageTitle(intl.get('preferences.estimates')); + }, [changePreferencesPageTitle]); + + // Initial values. + const initialValues = { + ...defaultValues, + ...transformToForm(estimatesSettings, defaultValues), + }; + // Handle the form submit. + const handleFormSubmit = (values, { setSubmitting }) => { + const options = R.compose( + transferObjectOptionsToArray, + transfromToSnakeCase, + )({ salesEstimates: { ...values } }); + + // Handle request success. + const onSuccess = (response) => { + AppToaster.show({ + message: intl.get('preferences.estimates.success_message'), + intent: Intent.SUCCESS, + }); + setSubmitting(false); + }; + // Handle request error. + const onError = () => { + setSubmitting(false); + }; + saveSettingMutate({ options }).then(onSuccess).catch(onError); + }; + + return ( + + ); +} + +export const PreferencesEstimatesFormPage = compose( + withDashboardActions, + withSettings(({ estimatesSettings }) => ({ + estimatesSettings: estimatesSettings, + })), +)(PreferencesEstimatesFormPageRoot); diff --git a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceForm.schema.ts b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceForm.schema.ts new file mode 100644 index 000000000..be7bead85 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceForm.schema.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import * as Yup from 'yup'; + +const Schema = Yup.object().shape({ + termsConditions: Yup.string().optional(), + customerNotes: Yup.string().optional(), +}); + +export const PreferencesInvoiceFormSchema = Schema; diff --git a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormBoot.tsx b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormBoot.tsx new file mode 100644 index 000000000..2cd42db21 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormBoot.tsx @@ -0,0 +1,53 @@ +// @ts-nocheck +import React from 'react'; +import classNames from 'classnames'; +import styled from 'styled-components'; +import { CLASSES } from '@/constants/classes'; +import { useSettings } from '@/hooks/query'; +import PreferencesPageLoader from '../PreferencesPageLoader'; +import { Card } from '@/components'; + +const PreferencesInvoiceFormContext = React.createContext(); + +function PreferencesInvoicesBoot({ ...props }) { + // Fetches organization settings. + const { isLoading: isSettingsLoading } = useSettings(); + + // Provider state. + const provider = { + isSettingsLoading + }; + + // Detarmines whether if any query is loading. + const isLoading = isSettingsLoading; + + return ( +
+ + {isLoading ? ( + + ) : ( + + )} + +
+ ); +} + +const PreferencesInvoicesCard = styled(Card)` + padding: 25px; + + .bp4-form-group{ + max-width: 600px; + } +`; + +const usePreferencesInvoiceFormContext = () => + React.useContext(PreferencesInvoiceFormContext); + +export { PreferencesInvoicesBoot, usePreferencesInvoiceFormContext }; diff --git a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormPage.tsx b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormPage.tsx new file mode 100644 index 000000000..097b8f996 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormPage.tsx @@ -0,0 +1,82 @@ +// @ts-nocheck +import React, { useEffect } from 'react'; +import intl from 'react-intl-universal'; +import { Formik } from 'formik'; +import { Intent } from '@blueprintjs/core'; +import * as R from 'ramda'; + +import { AppToaster } from '@/components'; +import { PreferencesInvoiceFormSchema } from './PreferencesInvoiceForm.schema'; +import { PreferencesInvoicesForm } from './PreferencesInvoicesForm'; +import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; + +import { compose, transformToForm, transfromToSnakeCase } from '@/utils'; +import withSettings from '@/containers/Settings/withSettings'; +import { transferObjectOptionsToArray } from '../Accountant/utils'; +import { useSaveSettings } from '@/hooks/query'; + +const defaultValues = { + termsConditions: '', + customerNotes: '', +}; + +/** + * Preferences - Invoices. + */ +function PreferencesInvoiceFormPage({ + // #withDashboardActions + changePreferencesPageTitle, + + // #withSettings + invoiceSettings, +}) { + // Save settings. + const { mutateAsync: saveSettingMutate } = useSaveSettings(); + + useEffect(() => { + changePreferencesPageTitle(intl.get('preferences.invoices')); + }, [changePreferencesPageTitle]); + + // Initial values. + const initialValues = { + ...defaultValues, + ...transformToForm(invoiceSettings, defaultValues), + }; + // Handle the form submit. + const handleFormSubmit = (values, { setSubmitting }) => { + const options = R.compose( + transferObjectOptionsToArray, + transfromToSnakeCase, + )({ salesInvoices: { ...values } }); + + // Handle request success. + const onSuccess = () => { + AppToaster.show({ + message: intl.get('preferences.invoices.success_message'), + intent: Intent.SUCCESS, + }); + setSubmitting(false); + }; + // Handle request error. + const onError = () => { + setSubmitting(false); + }; + saveSettingMutate({ options }).then(onSuccess).catch(onError); + }; + + return ( + + ); +} + +export default compose( + withDashboardActions, + withSettings(({ invoiceSettings }) => ({ + invoiceSettings: invoiceSettings, + })), +)(PreferencesInvoiceFormPage); diff --git a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoices.tsx b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoices.tsx new file mode 100644 index 000000000..da349ea9a --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoices.tsx @@ -0,0 +1,14 @@ +// @ts-nocheck +import { PreferencesInvoicesBoot } from './PreferencesInvoiceFormBoot'; +import PreferencesInvoiceFormPage from './PreferencesInvoiceFormPage'; + +/** + * items preferences. + */ +export default function PreferencesInvoices() { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoicesForm.tsx b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoicesForm.tsx new file mode 100644 index 000000000..9237c58e8 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoicesForm.tsx @@ -0,0 +1,74 @@ +// @ts-nocheck +import styled from 'styled-components'; +import { Form } from 'formik'; +import { Button, Intent } from '@blueprintjs/core'; +import { useHistory } from 'react-router-dom'; + +import { FormattedMessage as T, FFormGroup, FTextArea } from '@/components'; + +/** + * Invoices preferences form. + */ +export function PreferencesInvoicesForm({ isSubmitting }) { + const history = useHistory(); + + // Handle close click. + const handleCloseClick = () => { + history.go(-1); + }; + + return ( +
+ {/* ---------- Customer Notes ---------- */} + } + fastField={true} + > + + + + {/* ---------- Terms & Conditions ---------- */} + } + fastField={true} + > + + + + + + + +
+ ); +} + +const CardFooterActions = styled.div` + padding-top: 16px; + border-top: 1px solid #e0e7ea; + margin-top: 30px; + + .bp4-button { + min-width: 70px; + + + .bp4-button { + margin-left: 10px; + } + } +`; diff --git a/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceipts.tsx b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceipts.tsx new file mode 100644 index 000000000..83fc663a2 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceipts.tsx @@ -0,0 +1,14 @@ +// @ts-nocheck +import { PreferencesReceiptsBoot } from './PreferencesReceiptsFormBoot'; +import { PreferencesReceiptsFormPage } from './PreferencesReceiptsFormPage'; + +/** + * Preferences - Receipts. + */ +export function PreferencesReceipts() { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsForm.schema.ts b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsForm.schema.ts new file mode 100644 index 000000000..f28cc9407 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsForm.schema.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import * as Yup from 'yup'; + +const Schema = Yup.object().shape({ + termsConditions: Yup.string().optional(), + customerNotes: Yup.string().optional(), +}); + +export const PreferencesReceiptsFormSchema = Schema; diff --git a/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsForm.tsx b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsForm.tsx new file mode 100644 index 000000000..42836de51 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsForm.tsx @@ -0,0 +1,74 @@ +// @ts-nocheck +import styled from 'styled-components'; +import { Form } from 'formik'; +import { Button, Intent } from '@blueprintjs/core'; +import { useHistory } from 'react-router-dom'; + +import { FormattedMessage as T, FFormGroup, FTextArea } from '@/components'; + +/** + * Preferences general form. + */ +export function PreferencesReceiptsForm({ isSubmitting }) { + const history = useHistory(); + + // Handle close click. + const handleCloseClick = () => { + history.go(-1); + }; + + return ( +
+ {/* ---------- Customer Notes ---------- */} + } + fastField={true} + > + + + + {/* ---------- Terms & Conditions ---------- */} + } + fastField={true} + > + + + + + + + +
+ ); +} + +const CardFooterActions = styled.div` + padding-top: 16px; + border-top: 1px solid #e0e7ea; + margin-top: 30px; + + .bp4-button { + min-width: 70px; + + + .bp4-button { + margin-left: 10px; + } + } +`; diff --git a/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsFormBoot.tsx b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsFormBoot.tsx new file mode 100644 index 000000000..539980a4e --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsFormBoot.tsx @@ -0,0 +1,56 @@ +// @ts-nocheck +import React from 'react'; +import classNames from 'classnames'; +import styled from 'styled-components'; +import { CLASSES } from '@/constants/classes'; +import { useSettings } from '@/hooks/query'; +import PreferencesPageLoader from '../PreferencesPageLoader'; +import { Card } from '@/components'; + +const PreferencesReceiptsFormContext = React.createContext(); + +function PreferencesReceiptsBoot({ ...props }) { + // Fetches organization settings. + const { isLoading: isSettingsLoading } = useSettings(); + + // Provider state. + const provider = { + isSettingsLoading, + }; + + // Detarmines whether if any query is loading. + const isLoading = isSettingsLoading; + + return ( +
+ + {isLoading ? ( + + ) : ( + + )} + +
+ ); +} + +const PreferencesReceiptsCard = styled(Card)` + padding: 25px; + + .bp4-form-group { + max-width: 600px; + } +`; + +const usePreferencesReceiptsFormContext = () => + React.useContext(PreferencesReceiptsFormContext); + +export { PreferencesReceiptsBoot, usePreferencesReceiptsFormContext }; diff --git a/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsFormPage.tsx b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsFormPage.tsx new file mode 100644 index 000000000..f5da0d06a --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsFormPage.tsx @@ -0,0 +1,82 @@ +// @ts-nocheck +import React, { useEffect } from 'react'; +import intl from 'react-intl-universal'; +import { Formik } from 'formik'; +import { Intent } from '@blueprintjs/core'; +import * as R from 'ramda'; + +import { AppToaster } from '@/components'; +import { PreferencesReceiptsFormSchema } from './PreferencesReceiptsForm.schema'; +import { PreferencesReceiptsForm } from './PreferencesReceiptsForm'; +import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; + +import { compose, transformToForm, transfromToSnakeCase } from '@/utils'; +import withSettings from '@/containers/Settings/withSettings'; +import { useSaveSettings } from '@/hooks/query'; +import { transferObjectOptionsToArray } from '../Accountant/utils'; + +const defaultValues = { + termsConditions: '', + receiptMessage: '', +}; + +/** + * Preferences - Receipts. + */ +function PreferencesReceiptsFormPageRoot({ + // #withDashboardActions + changePreferencesPageTitle, + + // #withSettings + receiptSettings, +}) { + // Save settings. + const { mutateAsync: saveSettingMutate } = useSaveSettings(); + + useEffect(() => { + changePreferencesPageTitle(intl.get('preferences.receipts')); + }, [changePreferencesPageTitle]); + + // Initial values. + const initialValues = { + ...defaultValues, + ...transformToForm(receiptSettings, defaultValues), + }; + // Handle the form submit. + const handleFormSubmit = (values, { setSubmitting }) => { + const options = R.compose( + transferObjectOptionsToArray, + transfromToSnakeCase, + )({ salesReceipts: { ...values } }); + + // Handle request success. + const onSuccess = () => { + AppToaster.show({ + message: intl.get('preferences.receipts.success_message'), + intent: Intent.SUCCESS, + }); + setSubmitting(false); + }; + // Handle request error. + const onError = () => { + setSubmitting(false); + }; + saveSettingMutate({ options }).then(onSuccess).catch(onError); + }; + + return ( + + ); +} + +export const PreferencesReceiptsFormPage = compose( + withDashboardActions, + withSettings(({ receiptSettings }) => ({ + receiptSettings: receiptSettings, + })), +)(PreferencesReceiptsFormPageRoot); diff --git a/packages/webapp/src/containers/Purchases/Bills/BillForm/utils.tsx b/packages/webapp/src/containers/Purchases/Bills/BillForm/utils.tsx index 2d82bfea5..f98f02757 100644 --- a/packages/webapp/src/containers/Purchases/Bills/BillForm/utils.tsx +++ b/packages/webapp/src/containers/Purchases/Bills/BillForm/utils.tsx @@ -67,6 +67,7 @@ export const ERRORS = { BILL_NUMBER_EXISTS: 'BILL.NUMBER.EXISTS', ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED: 'ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED', + BILL_AMOUNT_SMALLER_THAN_PAID_AMOUNT: 'BILL_AMOUNT_SMALLER_THAN_PAID_AMOUNT', }; /** * Transformes the bill to initial values of edit form. @@ -200,6 +201,14 @@ export const handleErrors = (errors, { setErrors }) => { }), ); } + if ( + errors.some((e) => e.type === ERRORS.BILL_AMOUNT_SMALLER_THAN_PAID_AMOUNT) + ) { + AppToaster.show({ + intent: Intent.DANGER, + message: intl.get('bill.total_smaller_than_paid_amount'), + }); + } }; export const useSetPrimaryBranchToForm = () => { @@ -322,8 +331,8 @@ export const useBillAggregatedTaxRates = () => { const { taxRates } = useBillFormContext(); const aggregateTaxRates = React.useMemo( - () => aggregateItemEntriesTaxRates(taxRates), - [taxRates], + () => aggregateItemEntriesTaxRates(values.currency_code, taxRates), + [values.currency_code, taxRates], ); // Calculate the total tax amount of bill entries. return React.useMemo(() => { diff --git a/packages/webapp/src/containers/Purchases/CreditNotes/VendorCreditIUniversalSearchBind.tsx b/packages/webapp/src/containers/Purchases/CreditNotes/VendorCreditIUniversalSearchBind.tsx index fa82987e3..a360ab617 100644 --- a/packages/webapp/src/containers/Purchases/CreditNotes/VendorCreditIUniversalSearchBind.tsx +++ b/packages/webapp/src/containers/Purchases/CreditNotes/VendorCreditIUniversalSearchBind.tsx @@ -22,7 +22,7 @@ function VendorCreditUniversalSearchSelectComponent({ openDrawer, }) { if (resourceType === RESOURCES_TYPES.VENDOR_CREDIT) { - openDrawer(DRAWERS.VENDOR_CREDIT_DETAIL_DRAWER, { + openDrawer(DRAWERS.VENDOR_CREDIT_DETAILS, { vendorCreditId: resourceId, }); onAction && onAction(); @@ -83,7 +83,7 @@ export function VendorCreditUniversalSearchItem( } label={ <> -
${item.reference.amount}
+
{item.reference.formatted_amount}
} diff --git a/packages/webapp/src/containers/Purchases/PaymentMades/PaymentForm/PaymentMadeEntriesTable.tsx b/packages/webapp/src/containers/Purchases/PaymentMades/PaymentForm/PaymentMadeEntriesTable.tsx index cd0228799..841a3f359 100644 --- a/packages/webapp/src/containers/Purchases/PaymentMades/PaymentForm/PaymentMadeEntriesTable.tsx +++ b/packages/webapp/src/containers/Purchases/PaymentMades/PaymentForm/PaymentMadeEntriesTable.tsx @@ -30,6 +30,7 @@ export default function PaymentMadeEntriesTable({ // Formik context. const { values: { vendor_id }, + errors, } = useFormikContext(); // Handle update data. @@ -63,7 +64,7 @@ export default function PaymentMadeEntriesTable({ data={entries} spinnerProps={false} payload={{ - errors: [], + errors: errors?.entries || [], updateData: handleUpdateData, currencyCode, }} diff --git a/packages/webapp/src/containers/Purchases/PaymentMades/PaymentMadeUniversalSearch.tsx b/packages/webapp/src/containers/Purchases/PaymentMades/PaymentMadeUniversalSearch.tsx index f1cdc60ce..890401038 100644 --- a/packages/webapp/src/containers/Purchases/PaymentMades/PaymentMadeUniversalSearch.tsx +++ b/packages/webapp/src/containers/Purchases/PaymentMades/PaymentMadeUniversalSearch.tsx @@ -7,6 +7,7 @@ import { Icon } from '@/components'; import { RESOURCES_TYPES } from '@/constants/resourcesTypes'; import { highlightText } from '@/utils'; import { AbilitySubject, PaymentMadeAction } from '@/constants/abilityOption'; +import { DRAWERS } from '@/constants/drawers'; import withDrawerActions from '@/containers/Drawer/withDrawerActions'; /** diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteForm.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteForm.tsx index ec8467061..a52afcac4 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteForm.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteForm.tsx @@ -5,7 +5,7 @@ import classNames from 'classnames'; import { useHistory } from 'react-router-dom'; import { Formik, Form } from 'formik'; import { Intent } from '@blueprintjs/core'; -import { isEmpty } from 'lodash'; +import { defaultTo, isEmpty } from 'lodash'; import { CLASSES } from '@/constants/classes'; import { CreateCreditNoteFormSchema, @@ -38,7 +38,10 @@ import { import withSettings from '@/containers/Settings/withSettings'; import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization'; -import { CreditNoteSyncIncrementSettingsToForm } from './components'; +import { + CreditNoteExchangeRateSync, + CreditNoteSyncIncrementSettingsToForm, +} from './components'; /** * Credit note form. @@ -48,6 +51,8 @@ function CreditNoteForm({ creditAutoIncrement, creditNumberPrefix, creditNextNumber, + creditCustomerNotes, + creditTermsConditions, // #withCurrentOrganization organization: { base_currency }, @@ -68,22 +73,21 @@ function CreditNoteForm({ const creditNumber = transactionNumber(creditNumberPrefix, creditNextNumber); // Initial values. - const initialValues = React.useMemo( - () => ({ - ...(!isEmpty(creditNote) - ? { ...transformToEditForm(creditNote) } - : { - ...defaultCreditNote, - ...(creditAutoIncrement && { - credit_note_number: creditNumber, - }), - entries: orderingLinesIndexes(defaultCreditNote.entries), - currency_code: base_currency, - ...newCreditNote, + const initialValues = { + ...(!isEmpty(creditNote) + ? { ...transformToEditForm(creditNote) } + : { + ...defaultCreditNote, + ...(creditAutoIncrement && { + credit_note_number: creditNumber, }), - }), - [], - ); + entries: orderingLinesIndexes(defaultCreditNote.entries), + currency_code: base_currency, + terms_conditions: defaultTo(creditTermsConditions, ''), + note: defaultTo(creditCustomerNotes, ''), + ...newCreditNote, + }), + }; // Handles form submit. const handleFormSubmit = ( @@ -168,6 +172,7 @@ function CreditNoteForm({ {/*-------- Effects --------*/} +
@@ -178,6 +183,8 @@ export default compose( creditAutoIncrement: creditNoteSettings?.autoIncrement, creditNextNumber: creditNoteSettings?.nextNumber, creditNumberPrefix: creditNoteSettings?.numberPrefix, + creditCustomerNotes: creditNoteSettings?.customerNotes, + creditTermsConditions: creditNoteSettings?.termsConditions, })), withCurrentOrganization(), )(CreditNoteForm); diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormHeaderFields.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormHeaderFields.tsx index cd2fc1d1a..3c89f2f04 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormHeaderFields.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormHeaderFields.tsx @@ -26,6 +26,7 @@ import { inputIntent, handleDateChange, } from '@/utils'; +import { useCustomerUpdateExRate } from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; /** * Credit note form header fields. @@ -37,10 +38,8 @@ export default function CreditNoteFormHeaderFields({}) { {/* ----------- Exchange rate ----------- */} - + + {/* ----------- Credit note date ----------- */} {({ form, field: { value }, meta: { error, touched } }) => ( @@ -93,8 +92,18 @@ export default function CreditNoteFormHeaderFields({}) { */ function CreditNoteCustomersSelect() { // Credit note form context. - const { customers } = useCreditNoteFormContext(); const { setFieldValue, values } = useFormikContext(); + const { customers } = useCreditNoteFormContext(); + + const updateEntries = useCustomerUpdateExRate(); + + // Handles item change. + const handleItemChange = (customer) => { + setFieldValue('customer_id', customer.id); + setFieldValue('currency_code', customer?.currency_code); + + updateEntries(customer); + }; return ( } - onItemChange={(customer) => { - setFieldValue('customer_id', customer.id); - setFieldValue('currency_code', customer?.currency_code); - }} + onItemChange={handleItemChange} popoverFill={true} allowCreate={true} fastField={true} diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormPage.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormPage.tsx index bb4c7a2cd..872c2f053 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormPage.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormPage.tsx @@ -6,6 +6,7 @@ import '@/style/pages/CreditNote/PageForm.scss'; import CreditNoteForm from './CreditNoteForm'; import { CreditNoteFormProvider } from './CreditNoteFormProvider'; +import { AutoExchangeRateProvider } from '@/containers/Entries/AutoExchangeProvider'; /** * Credit note form page. @@ -16,7 +17,9 @@ export default function CreditNoteFormPage() { return ( - + + + ); } diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/components.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/components.tsx index 2902299fa..afe139f48 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/components.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/components.tsx @@ -1,21 +1,27 @@ // @ts-nocheck -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import { useFormikContext } from 'formik'; import * as R from 'ramda'; import { ExchangeRateInputGroup } from '@/components'; import { useCurrentOrganization } from '@/hooks/state'; -import { useCreditNoteIsForeignCustomer } from './utils'; +import { useCreditNoteIsForeignCustomer, useCreditNoteTotals } from './utils'; import withSettings from '@/containers/Settings/withSettings'; import { transactionNumber } from '@/utils'; +import { + useSyncExRateToForm, + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +} from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DialogsName } from '@/constants/dialogs'; /** - * credit exchange rate input field. + * Credit note exchange rate input field. * @returns {JSX.Element} */ -export function CreditNoteExchangeRateInputField({ ...props }) { +function CreditNoteExchangeRateInputFieldRoot({ ...props }) { const currentOrganization = useCurrentOrganization(); const { values } = useFormikContext(); - const isForeignCustomer = useCreditNoteIsForeignCustomer(); // Can't continue if the customer is not foreign. @@ -24,13 +30,21 @@ export function CreditNoteExchangeRateInputField({ ...props }) { } return ( ); } +export const CreditNoteExchangeRateInputField = R.compose( + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +)(CreditNoteExchangeRateInputFieldRoot); + /** * Syncs credit note auto-increment settings to form. * @return {React.ReactNode} @@ -56,3 +70,28 @@ export const CreditNoteSyncIncrementSettingsToForm = R.compose( return null; }); + +/** + * Syncs the realtime exchange rate to the credit note form and shows up popup to the user + * as an indication the entries rates have been re-calculated. + * @returns {React.ReactNode} + */ +export const CreditNoteExchangeRateSync = R.compose(withDialogActions)( + ({ openDialog }) => { + const { total } = useCreditNoteTotals(); + const timeout = useRef(); + + useSyncExRateToForm({ + onSynced: () => { + // If the total bigger then zero show alert to the user after adjusting entries. + if (total > 0) { + clearTimeout(timeout.current); + timeout.current = setTimeout(() => { + openDialog(DialogsName.InvoiceExchangeRateChangeNotice); + }, 500); + } + }, + }); + return null; + }, +); diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteUniversalSearch.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteUniversalSearch.tsx index 2c495f802..8407a4f39 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteUniversalSearch.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteUniversalSearch.tsx @@ -82,7 +82,7 @@ export function CreditNoteUniversalSearchItem( } label={ <> -
${item.reference.amount}
+
{item.reference.formatted_amount}
} diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialog.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialog.tsx new file mode 100644 index 000000000..6a0b832c3 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialog.tsx @@ -0,0 +1,39 @@ +// @ts-nocheck +import React from 'react'; +import { Dialog, DialogSuspense } from '@/components'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import { compose } from '@/utils'; + +const EstimateFormMailDeliverDialogContent = React.lazy( + () => import('./EstimateFormMailDeliverDialogContent'), +); + +/** + * Estimate mail dialog. + */ +function EstimateFormMailDeliverDialog({ + dialogName, + payload: { estimateId = null }, + isOpen, +}) { + return ( + + + + + + ); +} + +export default compose(withDialogRedux())(EstimateFormMailDeliverDialog); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialogContent.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialogContent.tsx new file mode 100644 index 000000000..e77e3ee99 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialogContent.tsx @@ -0,0 +1,40 @@ +// @ts-nocheck +import * as R from 'ramda'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { useHistory } from 'react-router-dom'; +import EstimateMailDialogContent from '../../EstimateMailDialog/EstimateMailDialogContent'; +import { DialogsName } from '@/constants/dialogs'; + +interface EstimateFormDeliverDialogContent { + estimateId: number; +} + +function EstimateFormDeliverDialogContentRoot({ + estimateId, + + // #withDialogActions + closeDialog, +}: EstimateFormDeliverDialogContent) { + const history = useHistory(); + + const handleSubmit = () => { + closeDialog(DialogsName.EstimateFormMailDeliver); + history.push('/estimates'); + }; + const handleCancel = () => { + closeDialog(DialogsName.EstimateFormMailDeliver); + history.push('/estimates'); + }; + + return ( + + ); +} + +export default R.compose(withDialogActions)( + EstimateFormDeliverDialogContentRoot, +); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx index a4db1cfff..aff0888d6 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx @@ -1,10 +1,9 @@ // @ts-nocheck -import React, { useMemo } from 'react'; import intl from 'react-intl-universal'; import classNames from 'classnames'; import { Formik, Form } from 'formik'; import { Intent } from '@blueprintjs/core'; -import { sumBy, isEmpty } from 'lodash'; +import { sumBy, isEmpty, defaultTo } from 'lodash'; import { useHistory } from 'react-router-dom'; import { CLASSES } from '@/constants/classes'; @@ -19,7 +18,10 @@ import EstimateFloatingActions from './EstimateFloatingActions'; import EstimateFormFooter from './EstimateFormFooter'; import EstimateFormDialogs from './EstimateFormDialogs'; import EstimtaeFormTopBar from './EstimtaeFormTopBar'; -import { EstimateIncrementSyncSettingsToForm } from './components'; +import { + EstimateIncrementSyncSettingsToForm, + EstimateSyncAutoExRateToForm, +} from './components'; import withSettings from '@/containers/Settings/withSettings'; import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization'; @@ -43,6 +45,8 @@ function EstimateForm({ estimateNextNumber, estimateNumberPrefix, estimateAutoIncrementMode, + estimateCustomerNotes, + estimateTermsConditions, // #withCurrentOrganization organization: { base_currency }, @@ -60,25 +64,23 @@ function EstimateForm({ estimateNumberPrefix, estimateNextNumber, ); - // Initial values in create and edit mode. - const initialValues = useMemo( - () => ({ - ...(!isEmpty(estimate) - ? { ...transformToEditForm(estimate) } - : { - ...defaultEstimate, - // If the auto-increment mode is enabled, take the next estimate - // number from the settings. - ...(estimateAutoIncrementMode && { - estimate_number: estimateNumber, - }), - entries: orderingLinesIndexes(defaultEstimate.entries), - currency_code: base_currency, + const initialValues = { + ...(!isEmpty(estimate) + ? { ...transformToEditForm(estimate) } + : { + ...defaultEstimate, + // If the auto-increment mode is enabled, take the next estimate + // number from the settings. + ...(estimateAutoIncrementMode && { + estimate_number: estimateNumber, }), - }), - [estimate, estimateNumber, estimateAutoIncrementMode, base_currency], - ); + entries: orderingLinesIndexes(defaultEstimate.entries), + currency_code: base_currency, + terms_conditions: defaultTo(estimateTermsConditions, ''), + note: defaultTo(estimateCustomerNotes, ''), + }), + }; // Handles form submit. const handleFormSubmit = ( @@ -170,6 +172,7 @@ function EstimateForm({ {/*------- Effects -------*/} +
@@ -181,6 +184,8 @@ export default compose( estimateNextNumber: estimatesSettings?.nextNumber, estimateNumberPrefix: estimatesSettings?.numberPrefix, estimateAutoIncrementMode: estimatesSettings?.autoIncrement, + estimateCustomerNotes: estimatesSettings?.customerNotes, + estimateTermsConditions: estimatesSettings?.termsConditions, })), withCurrentOrganization(), )(EstimateForm); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormDialogs.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormDialogs.tsx index aa1c165a1..a50326486 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormDialogs.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormDialogs.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { useFormikContext } from 'formik'; import EstimateNumberDialog from '@/containers/Dialogs/EstimateNumberDialog'; +import EstimateFormMailDeliverDialog from './Dialogs/EstimateFormMailDeliverDialog'; +import { DialogsName } from '@/constants/dialogs'; /** * Estimate form dialogs. @@ -25,6 +27,9 @@ export default function EstimateFormDialogs() { dialogName={'estimate-number-form'} onConfirm={handleEstimateNumberFormConfirm} /> + ); } diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeaderFields.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeaderFields.tsx index e3bfabb9a..eba6eb5da 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeaderFields.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeaderFields.tsx @@ -1,5 +1,4 @@ // @ts-nocheck -import React from 'react'; import styled from 'styled-components'; import classNames from 'classnames'; import { FormGroup, InputGroup, Position, Classes } from '@blueprintjs/core'; @@ -24,7 +23,6 @@ import { import { customersFieldShouldUpdate } from './utils'; import { CLASSES } from '@/constants/classes'; import { Features } from '@/constants'; - import { ProjectsSelect } from '@/containers/Projects/components'; import { EstimateExchangeRateInputField, @@ -32,12 +30,13 @@ import { } from './components'; import { EstimateFormEstimateNumberField } from './EstimateFormEstimateNumberField'; import { useEstimateFormContext } from './EstimateFormProvider'; +import { useCustomerUpdateExRate } from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; /** * Estimate form header. */ export default function EstimateFormHeader() { - const { customers, projects } = useEstimateFormContext(); + const { projects } = useEstimateFormContext(); return (
@@ -45,10 +44,8 @@ export default function EstimateFormHeader() { {/* ----------- Exchange Rate ----------- */} - + + {/* ----------- Estimate Date ----------- */} {({ form, field: { value }, meta: { error, touched } }) => ( @@ -151,6 +148,16 @@ function EstimateFormCustomerSelect() { const { setFieldValue, values } = useFormikContext(); const { customers } = useEstimateFormContext(); + const updateEntries = useCustomerUpdateExRate(); + + // Handles the customer item change. + const handleItemChange = (customer) => { + setFieldValue('customer_id', customer.id); + setFieldValue('currency_code', customer?.currency_code); + + updateEntries(customer); + }; + return ( } @@ -165,10 +172,7 @@ function EstimateFormCustomerSelect() { name={'customer_id'} items={customers} placeholder={} - onItemChange={(customer) => { - setFieldValue('customer_id', customer.id); - setFieldValue('currency_code', customer?.currency_code); - }} + onItemChange={handleItemChange} popoverFill={true} allowCreate={true} fastField={true} diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormPage.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormPage.tsx index 6f02669db..0ca5d4e37 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormPage.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormPage.tsx @@ -6,6 +6,7 @@ import '@/style/pages/SaleEstimate/PageForm.scss'; import EstimateForm from './EstimateForm'; import { EstimateFormProvider } from './EstimateFormProvider'; +import { AutoExchangeRateProvider } from '@/containers/Entries/AutoExchangeProvider'; /** * Estimate form page. @@ -16,7 +17,9 @@ export default function EstimateFormPage() { return ( - + + + ); } diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/components.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/components.tsx index 65a1c9cd2..3cace61f2 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/components.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/components.tsx @@ -1,24 +1,30 @@ // @ts-nocheck -import React, { useEffect } from 'react'; +import React, { useRef } from 'react'; import intl from 'react-intl-universal'; import { Button } from '@blueprintjs/core'; import * as R from 'ramda'; import { useFormikContext } from 'formik'; import { ExchangeRateInputGroup } from '@/components'; import { useCurrentOrganization } from '@/hooks/state'; -import { useEstimateIsForeignCustomer } from './utils'; -import withSettings from '@/containers/Settings/withSettings'; +import { useEstimateIsForeignCustomer, useEstimateTotals } from './utils'; import { transactionNumber } from '@/utils'; import { useUpdateEffect } from '@/hooks'; +import withSettings from '@/containers/Settings/withSettings'; +import { + useSyncExRateToForm, + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +} from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DialogsName } from '@/constants/dialogs'; /** * Estimate exchange rate input field. * @returns {JSX.Element} */ -export function EstimateExchangeRateInputField({ ...props }) { +function EstimateExchangeRateInputFieldRoot({ ...props }) { const currentOrganization = useCurrentOrganization(); const { values } = useFormikContext(); - const isForeignCustomer = useEstimateIsForeignCustomer(); // Can't continue if the customer is not foreign. @@ -27,13 +33,26 @@ export function EstimateExchangeRateInputField({ ...props }) { } return ( ); } +/** + * Renders the estimate exchange rate input field with exchange rate + * with item entries price re-calc once exchange rate change. + * @returns {JSX.Element} + */ +export const EstimateExchangeRateInputField = R.compose( + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +)(EstimateExchangeRateInputFieldRoot); + /** * Estimate project select. * @returns {JSX.Element} @@ -72,3 +91,32 @@ export const EstimateIncrementSyncSettingsToForm = R.compose( return null; }); + +/** + * Syncs the auto exchange rate to the estimate form and shows up popup to user + * as an indication the entries rates have been changed. + * @returns {React.ReactNode} + */ +export const EstimateSyncAutoExRateToForm = R.compose(withDialogActions)( + ({ + // #withDialogActions + openDialog, + }) => { + const { total } = useEstimateTotals(); + const timeout = useRef(); + + useSyncExRateToForm({ + onSynced: () => { + // If the total bigger then zero show alert to the user after adjusting entries. + if (total > 0) { + clearTimeout(timeout.current); + timeout.current = setTimeout(() => { + openDialog(DialogsName.InvoiceExchangeRateChangeNotice); + }, 500); + } + }, + }); + + return null; + }, +); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog.tsx new file mode 100644 index 000000000..0d13e07fb --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog.tsx @@ -0,0 +1,35 @@ +// @ts-nocheck +import React from 'react'; +import { Dialog, DialogSuspense } from '@/components'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import { compose } from '@/utils'; + +const EstimateMailDialogBody = React.lazy( + () => import('./EstimateMailDialogBody'), +); + +/** + * Estimate mail dialog. + */ +function EstimateMailDialog({ + dialogName, + payload: { estimateId = null }, + isOpen, +}) { + return ( + + + + + + ); +} + +export default compose(withDialogRedux())(EstimateMailDialog); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogBody.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogBody.tsx new file mode 100644 index 000000000..2fa1c0472 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogBody.tsx @@ -0,0 +1,33 @@ +// @ts-nocheck +import * as R from 'ramda'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import EstimateMailDialogContent from './EstimateMailDialogContent'; +import { DialogsName } from '@/constants/dialogs'; + +interface EstimateMailDialogBodyProps { + estimateId: number; +} + +function EstimateMailDialogBodyRoot({ + estimateId, + + // #withDialogActions + closeDialog, +}: EstimateMailDialogBodyProps) { + const handleSubmit = () => { + closeDialog(DialogsName.EstimateMail); + }; + const handleCancelClick = () => { + closeDialog(DialogsName.EstimateMail); + }; + + return ( + + ); +} + +export default R.compose(withDialogActions)(EstimateMailDialogBodyRoot); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogBoot.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogBoot.tsx new file mode 100644 index 000000000..65b05a9c1 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogBoot.tsx @@ -0,0 +1,48 @@ +// @ts-nocheck +import React, { createContext } from 'react'; +import { useSaleEstimateDefaultOptions } from '@/hooks/query'; +import { DialogContent } from '@/components'; + +interface EstimateMailDialogBootValues { + estimateId: number; + mailOptions: any; + redirectToEstimatesList: boolean; +} + +const EstimateMailDialagBoot = createContext(); + +interface EstimateMailDialogBootProps { + estimateId: number; + redirectToEstimatesList?: boolean; + children: React.ReactNode; +} + +/** + * Estimate mail dialog boot provider. + */ +function EstimateMailDialogBoot({ + estimateId, + redirectToEstimatesList, + ...props +}: EstimateMailDialogBootProps) { + const { data: mailOptions, isLoading: isMailOptionsLoading } = + useSaleEstimateDefaultOptions(estimateId); + + const provider = { + saleEstimateId: estimateId, + mailOptions, + isMailOptionsLoading, + redirectToEstimatesList, + }; + + return ( + + + + ); +} + +const useEstimateMailDialogBoot = () => + React.useContext(EstimateMailDialagBoot); + +export { EstimateMailDialogBoot, useEstimateMailDialogBoot }; diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogContent.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogContent.tsx new file mode 100644 index 000000000..c673f71c6 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogContent.tsx @@ -0,0 +1,22 @@ +import { EstimateMailDialogBoot } from './EstimateMailDialogBoot'; +import { EstimateMailDialogForm } from './EstimateMailDialogForm'; + +interface EstimateMailDialogContentProps { + estimateId: number; + onFormSubmit?: () => void; + onCancelClick?: () => void; +} +export default function EstimateMailDialogContent({ + estimateId, + onFormSubmit, + onCancelClick, +}: EstimateMailDialogContentProps) { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogForm.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogForm.tsx new file mode 100644 index 000000000..8f51add43 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogForm.tsx @@ -0,0 +1,81 @@ +// @ts-nocheck +import { Formik } from 'formik'; +import * as R from 'ramda'; +import { Intent } from '@blueprintjs/core'; +import { useEstimateMailDialogBoot } from './EstimateMailDialogBoot'; +import { DialogsName } from '@/constants/dialogs'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { useSendSaleEstimateMail } from '@/hooks/query'; +import { EstimateMailDialogFormContent } from './EstimateMailDialogFormContent'; +import { + initialMailNotificationValues, + MailNotificationFormValues, + transformMailFormToInitialValues, + transformMailFormToRequest, +} from '@/containers/SendMailNotification/utils'; +import { AppToaster } from '@/components'; + +const initialFormValues = { + ...initialMailNotificationValues, + attachEstimate: true, +}; + +interface EstimateMailFormValues extends MailNotificationFormValues { + attachEstimate: boolean; +} + +function EstimateMailDialogFormRoot({ + onFormSubmit, + onCancelClick, + + // #withDialogClose + closeDialog, +}) { + const { mutateAsync: sendEstimateMail } = useSendSaleEstimateMail(); + const { mailOptions, saleEstimateId, redirectToEstimatesList } = + useEstimateMailDialogBoot(); + + const initialValues = transformMailFormToInitialValues( + mailOptions, + initialFormValues, + ); + // Handle the form submitting. + const handleSubmit = (values: EstimateMailFormValues, { setSubmitting }) => { + const reqValues = transformMailFormToRequest(values); + + setSubmitting(true); + sendEstimateMail([saleEstimateId, reqValues]) + .then(() => { + AppToaster.show({ + message: 'The mail notification has been sent successfully.', + intent: Intent.SUCCESS, + }); + closeDialog(DialogsName.EstimateMail); + setSubmitting(false); + onFormSubmit && onFormSubmit(); + }) + .catch(() => { + setSubmitting(false); + closeDialog(DialogsName.EstimateMail); + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + onCancelClick && onCancelClick(); + }); + }; + + const handleClose = () => { + closeDialog(DialogsName.EstimateMail); + }; + + return ( + + + + ); +} + +export const EstimateMailDialogForm = R.compose(withDialogActions)( + EstimateMailDialogFormRoot, +); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogFormContent.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogFormContent.tsx new file mode 100644 index 000000000..668c7a4c9 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogFormContent.tsx @@ -0,0 +1,66 @@ +// @ts-nocheck +import { Form, useFormikContext } from 'formik'; +import { Button, Classes, Intent } from '@blueprintjs/core'; +import styled from 'styled-components'; +import { FFormGroup, FSwitch } from '@/components'; +import { MailNotificationForm } from '@/containers/SendMailNotification'; +import { saveInvoke } from '@/utils'; +import { useEstimateMailDialogBoot } from './EstimateMailDialogBoot'; + +interface EstimateMailDialogFormContentProps { + onClose?: () => void; +} + +export function EstimateMailDialogFormContent({ + onClose, +}: EstimateMailDialogFormContentProps) { + const { isSubmitting } = useFormikContext(); + const { mailOptions } = useEstimateMailDialogBoot(); + + const handleClose = () => { + saveInvoke(onClose); + }; + + return ( +
+
+ + + + +
+ +
+
+ + + +
+
+
+ ); +} + +const AttachFormGroup = styled(FFormGroup)` + background: #f8f9fb; + margin-top: 0.6rem; + padding: 4px 14px; + border-radius: 5px; + border: 1px solid #dcdcdd; +`; diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/index.ts b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/index.ts new file mode 100644 index 000000000..bebbc8bef --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/index.ts @@ -0,0 +1 @@ +export * from './EstimateMailDialog'; \ No newline at end of file diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/EstimatesDataTable.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/EstimatesDataTable.tsx index 402771604..dec1d548c 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/EstimatesDataTable.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/EstimatesDataTable.tsx @@ -22,6 +22,7 @@ import { useEstimatesListContext } from './EstimatesListProvider'; import { useMemorizedColumnsWidths } from '@/hooks'; import { compose } from '@/utils'; import { DRAWERS } from '@/constants/drawers'; +import { DialogsName } from '@/constants/dialogs'; /** * Estimates datatable. @@ -100,6 +101,11 @@ function EstimatesDataTable({ openDrawer(DRAWERS.ESTIMATE_DETAILS, { estimateId: cell.row.original.id }); }; + // Handle mail send estimate. + const handleMailSendEstimate = ({ id }) => { + openDialog(DialogsName.EstimateMail, { estimateId: id }); + } + // Local storage memorizing columns widths. const [initialColumnsWidths, , handleColumnResizing] = useMemorizedColumnsWidths(TABLES.ESTIMATES); @@ -153,6 +159,7 @@ function EstimatesDataTable({ onConvert: handleConvertToInvoice, onViewDetails: handleViewDetailEstimate, onPrint: handlePrintEstimate, + onSendMail: handleMailSendEstimate, }} /> diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/components.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/components.tsx index a596a419a..5edfd9051 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/components.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/components.tsx @@ -64,6 +64,7 @@ export function ActionsMenu({ onConvert, onViewDetails, onPrint, + onSendMail }, }) { return ( @@ -129,6 +130,11 @@ export function ActionsMenu({ + } + text={'Send Mail'} + onClick={safeCallback(onSendMail, original)} + /> } text={intl.get('print')} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog.tsx new file mode 100644 index 000000000..2880ac383 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog.tsx @@ -0,0 +1,56 @@ +// @ts-nocheck +import { Dialog, DialogSuspense, FormattedMessage as T } from '@/components'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { compose } from '@/utils'; +import { Button, Classes, Intent } from '@blueprintjs/core'; + +/** + * Invoice number dialog. + */ +function InvoiceExchangeRateChangeDialog({ + dialogName, + isOpen, + // #withDialogActions + closeDialog, +}) { + const handleConfirm = () => { + closeDialog(dialogName); + }; + + return ( + {}} + > + +
+

+ The item rates have been adjusted to the new + currency using realtime exchange rate. +

+ +

+ Make sure to check that the item rates match the current exchange + rate of the newly selected currency before saving the transaction. +

+
+ +
+ +
+
+
+ ); +} + +export default compose( + withDialogRedux(), + withDialogActions, +)(InvoiceExchangeRateChangeDialog); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceFormMailDeliverDialog/InvoiceFormMailDeliverDialog.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceFormMailDeliverDialog/InvoiceFormMailDeliverDialog.tsx new file mode 100644 index 000000000..f6ceb38f8 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceFormMailDeliverDialog/InvoiceFormMailDeliverDialog.tsx @@ -0,0 +1,39 @@ +// @ts-nocheck +import React from 'react'; +import { Dialog, DialogSuspense } from '@/components'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import { compose } from '@/utils'; + +const InvoiceFormMailDeliverDialogContent = React.lazy( + () => import('./InvoiceFormMailDeliverDialogContent'), +); + +/** + * Invoice mail dialog. + */ +function InvoiceFormMailDeliverDialog({ + dialogName, + payload: { invoiceId = null }, + isOpen, +}) { + return ( + + + + + + ); +} + +export default compose(withDialogRedux())(InvoiceFormMailDeliverDialog); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceFormMailDeliverDialog/InvoiceFormMailDeliverDialogContent.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceFormMailDeliverDialog/InvoiceFormMailDeliverDialogContent.tsx new file mode 100644 index 000000000..8ce5e7c12 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceFormMailDeliverDialog/InvoiceFormMailDeliverDialogContent.tsx @@ -0,0 +1,40 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { useHistory } from 'react-router-dom'; +import InvoiceMailDialogContent from '../../../InvoiceMailDialog/InvoiceMailDialogContent'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DialogsName } from '@/constants/dialogs'; + +interface InvoiceFormDeliverDialogContent { + invoiceId: number; +} + +function InvoiceFormDeliverDialogContentRoot({ + invoiceId, + + // #withDialogActions + closeDialog, +}: InvoiceFormDeliverDialogContent) { + const history = useHistory(); + + const handleSubmit = () => { + history.push('/invoices'); + closeDialog(DialogsName.InvoiceFormMailDeliver); + }; + const handleCancel = () => { + history.push('/invoices'); + closeDialog(DialogsName.InvoiceFormMailDeliver); + }; + + return ( + + ); +} + +export default R.compose(withDialogActions)( + InvoiceFormDeliverDialogContentRoot, +); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/index.ts b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/index.ts new file mode 100644 index 000000000..cda7f24dc --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/index.ts @@ -0,0 +1,16 @@ +// @ts-nocheck +import { DialogsName } from '@/constants/dialogs'; +import React from 'react'; + +const InvoiceExchangeRateChangeAlert = React.lazy( + () => import('./InvoiceExchangeRateChangeDialog'), +); + +const Dialogs = [ + { + name: DialogsName.InvoiceExchangeRateChangeNotice, + component: InvoiceExchangeRateChangeAlert, + }, +]; + +export default Dialogs; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFloatingActions.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFloatingActions.tsx index 3de699979..e212c8b7d 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFloatingActions.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFloatingActions.tsx @@ -70,6 +70,7 @@ export default function InvoiceFloatingActions() { history.goBack(); }; + // Handle clear button click. const handleClearBtnClick = (event) => { resetForm(); }; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx index a8463619b..6b9f234ac 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx @@ -4,7 +4,7 @@ import intl from 'react-intl-universal'; import classNames from 'classnames'; import { Formik, Form } from 'formik'; import { Intent } from '@blueprintjs/core'; -import { sumBy, isEmpty } from 'lodash'; +import { sumBy, isEmpty, defaultTo } from 'lodash'; import { useHistory } from 'react-router-dom'; import { CLASSES } from '@/constants/classes'; import { @@ -34,7 +34,7 @@ import { transformValueToRequest, resetFormState, } from './utils'; -import { InvoiceNoSyncSettingsToForm } from './components'; +import { InvoiceExchangeRateSync, InvoiceNoSyncSettingsToForm } from './components'; /** * Invoice form. @@ -44,6 +44,8 @@ function InvoiceForm({ invoiceNextNumber, invoiceNumberPrefix, invoiceAutoIncrementMode, + invoiceCustomerNotes, + invoiceTermsConditions, // #withCurrentOrganization organization: { base_currency }, @@ -79,6 +81,8 @@ function InvoiceForm({ }), entries: orderingLinesIndexes(defaultInvoice.entries), currency_code: base_currency, + invoice_message: defaultTo(invoiceCustomerNotes, ''), + terms_conditions: defaultTo(invoiceTermsConditions, ''), ...newInvoice, }), }; @@ -180,6 +184,7 @@ function InvoiceForm({ {/*---------- Effects ----------*/} +
@@ -192,6 +197,8 @@ export default compose( invoiceNextNumber: invoiceSettings?.nextNumber, invoiceNumberPrefix: invoiceSettings?.numberPrefix, invoiceAutoIncrementMode: invoiceSettings?.autoIncrement, + invoiceCustomerNotes: invoiceSettings?.customerNotes, + invoiceTermsConditions: invoiceSettings?.termsConditions, })), withCurrentOrganization(), )(InvoiceForm); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormDialogs.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormDialogs.tsx index eb0e3e0ba..fca6a8bcb 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormDialogs.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormDialogs.tsx @@ -1,8 +1,8 @@ // @ts-nocheck -import React from 'react'; import { useFormikContext } from 'formik'; import InvoiceNumberDialog from '@/containers/Dialogs/InvoiceNumberDialog'; import { DialogsName } from '@/constants/dialogs'; +import InvoiceFormMailDeliverDialog from './Dialogs/InvoiceFormMailDeliverDialog/InvoiceFormMailDeliverDialog'; /** * Invoice form dialogs. @@ -23,9 +23,14 @@ export default function InvoiceFormDialogs() { }; return ( - + <> + + + ); } diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.tsx index 8697e52bc..17bd197e5 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.tsx @@ -36,6 +36,7 @@ import { ProjectBillableEntriesLink, } from '@/containers/Projects/components'; import { Features } from '@/constants'; +import { useCustomerUpdateExRate } from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; /** * Invoice form header fields. @@ -51,10 +52,8 @@ export default function InvoiceFormHeaderFields() { {/* ----------- Exchange rate ----------- */} - + + {/* ----------- Invoice date ----------- */} @@ -161,8 +160,20 @@ export default function InvoiceFormHeaderFields() { * @returns {React.ReactNode} */ function InvoiceFormCustomerSelect() { - const { customers } = useInvoiceFormContext(); const { values, setFieldValue } = useFormikContext(); + const { customers } = useInvoiceFormContext(); + + const updateEntries = useCustomerUpdateExRate(); + + // Handles the customer item change. + const handleItemChange = (customer) => { + // If the customer id has changed change the customer id and currency code. + if (values.customer_id !== customer.id) { + setFieldValue('customer_id', customer.id); + setFieldValue('currency_code', customer?.currency_code); + } + updateEntries(customer); + }; return ( } - onItemChange={(customer) => { - setFieldValue('customer_id', customer.id); - setFieldValue('currency_code', customer?.currency_code); - }} + onItemChange={handleItemChange} allowCreate={true} fastField={true} shouldUpdate={customerNameFieldShouldUpdate} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormPage.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormPage.tsx index eba22308e..42190b42a 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormPage.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormPage.tsx @@ -6,6 +6,7 @@ import '@/style/pages/SaleInvoice/PageForm.scss'; import InvoiceForm from './InvoiceForm'; import { InvoiceFormProvider } from './InvoiceFormProvider'; +import { AutoExchangeRateProvider } from '@/containers/Entries/AutoExchangeProvider'; /** * Invoice form page. @@ -16,7 +17,9 @@ export default function InvoiceFormPage() { return ( - + + + ); } diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/components.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/components.tsx index 0020a7e8d..788cd2c5c 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/components.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/components.tsx @@ -1,24 +1,30 @@ // @ts-nocheck -import React from 'react'; +import { useRef } from 'react'; import intl from 'react-intl-universal'; import * as R from 'ramda'; import { Button } from '@blueprintjs/core'; import { useFormikContext } from 'formik'; import { ExchangeRateInputGroup } from '@/components'; import { useCurrentOrganization } from '@/hooks/state'; -import { useInvoiceIsForeignCustomer } from './utils'; +import { useInvoiceIsForeignCustomer, useInvoiceTotal } from './utils'; import withSettings from '@/containers/Settings/withSettings'; import { useUpdateEffect } from '@/hooks'; import { transactionNumber } from '@/utils'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DialogsName } from '@/constants/dialogs'; +import { + useSyncExRateToForm, + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +} from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; /** * Invoice exchange rate input field. * @returns {JSX.Element} */ -export function InvoiceExchangeRateInputField({ ...props }) { +const InvoiceExchangeRateInputFieldRoot = ({ ...props }) => { const currentOrganization = useCurrentOrganization(); const { values } = useFormikContext(); - const isForeignCustomer = useInvoiceIsForeignCustomer(); // Can't continue if the customer is not foreign. @@ -27,12 +33,24 @@ export function InvoiceExchangeRateInputField({ ...props }) { } return ( ); -} +}; + +/** + * Invoice exchange rate input field. + * @returns {JSX.Element} + */ +export const InvoiceExchangeRateInputField = R.compose( + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +)(InvoiceExchangeRateInputFieldRoot); /** * Invoice project select. @@ -66,3 +84,28 @@ export const InvoiceNoSyncSettingsToForm = R.compose( return null; }); + +/** + * Syncs the realtime exchange rate to the invoice form and shows up popup to the user + * as an indication the entries rates have been re-calculated. + * @returns {React.ReactNode} + */ +export const InvoiceExchangeRateSync = R.compose(withDialogActions)( + ({ openDialog }) => { + const total = useInvoiceTotal(); + const timeout = useRef(); + + useSyncExRateToForm({ + onSynced: () => { + // If the total bigger then zero show alert to the user after adjusting entries. + if (total > 0) { + clearTimeout(timeout.current); + timeout.current = setTimeout(() => { + openDialog(DialogsName.InvoiceExchangeRateChangeNotice); + }, 500); + } + }, + }); + return null; + }, +); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx index 4186cbc63..a2ff9988a 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx @@ -5,7 +5,7 @@ import intl from 'react-intl-universal'; import moment from 'moment'; import * as R from 'ramda'; import { Intent } from '@blueprintjs/core'; -import { omit, first, sumBy } from 'lodash'; +import { omit, first, sumBy, round } from 'lodash'; import { compose, transformToForm, @@ -57,7 +57,7 @@ export const defaultInvoice = { reference_no: '', invoice_message: '', terms_conditions: '', - exchange_rate: 1, + exchange_rate: '1', currency_code: '', branch_id: '', warehouse_id: '', @@ -112,6 +112,16 @@ export const transformErrors = (errors, { setErrors }) => { intent: Intent.DANGER, }); } + if ( + errors.some( + ({ type }) => type === ERROR.INVOICE_AMOUNT_SMALLER_THAN_PAYMENT_AMOUNT, + ) + ) { + AppToaster.show({ + message: intl.get('sale_invoice.total_smaller_than_paid_amount'), + intent: Intent.DANGER, + }); + } if ( errors.some((error) => error.type === ERROR.SALE_INVOICE_NO_IS_REQUIRED) ) { @@ -333,8 +343,8 @@ export const useInvoiceAggregatedTaxRates = () => { const { taxRates } = useInvoiceFormContext(); const aggregateTaxRates = React.useMemo( - () => aggregateItemEntriesTaxRates(taxRates), - [taxRates], + () => aggregateItemEntriesTaxRates(values.currency_code, taxRates), + [values.currency_code, taxRates], ); // Calculate the total tax amount of invoice entries. return React.useMemo(() => { diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialog.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialog.tsx new file mode 100644 index 000000000..02c629e7c --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialog.tsx @@ -0,0 +1,35 @@ +// @ts-nocheck +import React from 'react'; +import { Dialog, DialogSuspense } from '@/components'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import { compose } from '@/utils'; + +const InvoiceMailDialogBody = React.lazy( + () => import('./InvoiceMailDialogBody'), +); + +/** + * Invoice mail dialog. + */ +function InvoiceMailDialog({ + dialogName, + payload: { invoiceId = null }, + isOpen, +}) { + return ( + + + + + + ); +} +export default compose(withDialogRedux())(InvoiceMailDialog); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogBody.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogBody.tsx new file mode 100644 index 000000000..3728c60ce --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogBody.tsx @@ -0,0 +1,36 @@ +// @ts-nocheck +import * as R from 'ramda'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import InvoiceMailDialogContent, { + InvoiceMailDialogContentProps, +} from './InvoiceMailDialogContent'; +import { DialogsName } from '@/constants/dialogs'; + +export interface InvoiceMailDialogBodyProps + extends InvoiceMailDialogContentProps {} + +function InvoiceMailDialogBodyRoot({ + invoiceId, + onCancelClick, + onFormSubmit, + + // #withDialogActions + closeDialog, +}: InvoiceMailDialogBodyProps) { + const handleCancelClick = () => { + closeDialog(DialogsName.InvoiceMail); + }; + const handleSubmitClick = () => { + closeDialog(DialogsName.InvoiceMail); + }; + + return ( + + ); +} + +export default R.compose(withDialogActions)(InvoiceMailDialogBodyRoot); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogBoot.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogBoot.tsx new file mode 100644 index 000000000..8c7d5f7e2 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogBoot.tsx @@ -0,0 +1,48 @@ +// @ts-nocheck +import React, { createContext } from 'react'; +import { useSaleInvoiceDefaultOptions } from '@/hooks/query'; +import { DialogContent } from '@/components'; + +interface InvoiceMailDialogBootValues { + invoiceId: number; + mailOptions: any; + redirectToInvoicesList: boolean; +} + +const InvoiceMailDialagBoot = createContext(); + +interface InvoiceMailDialogBootProps { + invoiceId: number; + redirectToInvoicesList?: boolean; + children: React.ReactNode; +} + +/** + * Invoice mail dialog boot provider. + */ +function InvoiceMailDialogBoot({ + invoiceId, + redirectToInvoicesList, + ...props +}: InvoiceMailDialogBootProps) { + const { data: mailOptions, isLoading: isMailOptionsLoading } = + useSaleInvoiceDefaultOptions(invoiceId); + + const provider = { + saleInvoiceId: invoiceId, + mailOptions, + isMailOptionsLoading, + redirectToInvoicesList, + }; + + return ( + + + + ); +} + +const useInvoiceMailDialogBoot = () => + React.useContext(InvoiceMailDialagBoot); + +export { InvoiceMailDialogBoot, useInvoiceMailDialogBoot }; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogContent.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogContent.tsx new file mode 100644 index 000000000..dbecb34fc --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogContent.tsx @@ -0,0 +1,22 @@ +import { InvoiceMailDialogBoot } from './InvoiceMailDialogBoot'; +import { InvoiceMailDialogForm } from './InvoiceMailDialogForm'; + +export interface InvoiceMailDialogContentProps { + invoiceId: number; + onFormSubmit?: () => void; + onCancelClick?: () => void; +} +export default function InvoiceMailDialogContent({ + invoiceId, + onFormSubmit, + onCancelClick, +}: InvoiceMailDialogContentProps) { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.schema.ts b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.schema.ts new file mode 100644 index 000000000..1c365ac4a --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.schema.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import * as Yup from 'yup'; + +export const InvoiceMailFormSchema = Yup.object().shape({ + from: Yup.array().required().min(1).max(5).label('From address'), + to: Yup.array().required().min(1).max(5).label('To address'), + subject: Yup.string().required().label('Mail subject'), + body: Yup.string().required().label('Mail body'), +}); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx new file mode 100644 index 000000000..a91c03466 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx @@ -0,0 +1,69 @@ +// @ts-nocheck +import { Formik } from 'formik'; +import { Intent } from '@blueprintjs/core'; +import { useInvoiceMailDialogBoot } from './InvoiceMailDialogBoot'; +import { AppToaster } from '@/components'; +import { useSendSaleInvoiceMail } from '@/hooks/query'; +import { InvoiceMailDialogFormContent } from './InvoiceMailDialogFormContent'; +import { InvoiceMailFormSchema } from './InvoiceMailDialogForm.schema'; +import { + MailNotificationFormValues, + initialMailNotificationValues, + transformMailFormToRequest, + transformMailFormToInitialValues, +} from '@/containers/SendMailNotification/utils'; + +const initialFormValues = { + ...initialMailNotificationValues, + attachInvoice: true, +}; + +interface InvoiceMailFormValues extends MailNotificationFormValues { + attachInvoice: boolean; +} + +export function InvoiceMailDialogForm({ onFormSubmit, onCancelClick }) { + const { mailOptions, saleInvoiceId } = useInvoiceMailDialogBoot(); + const { mutateAsync: sendInvoiceMail } = useSendSaleInvoiceMail(); + + const initialValues = transformMailFormToInitialValues( + mailOptions, + initialFormValues, + ); + // Handle the form submitting. + const handleSubmit = (values: InvoiceMailFormValues, { setSubmitting }) => { + const reqValues = transformMailFormToRequest(values); + + setSubmitting(true); + sendInvoiceMail([saleInvoiceId, reqValues]) + .then(() => { + AppToaster.show({ + message: 'The mail notification has been sent successfully.', + intent: Intent.SUCCESS, + }); + setSubmitting(false); + onFormSubmit && onFormSubmit(values); + }) + .catch(() => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + setSubmitting(false); + }); + }; + // Handle the close button click. + const handleClose = () => { + onCancelClick && onCancelClick(); + }; + + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogFormContent.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogFormContent.tsx new file mode 100644 index 000000000..07e104027 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogFormContent.tsx @@ -0,0 +1,66 @@ +// @ts-nocheck +import { Form, useFormikContext } from 'formik'; +import { Button, Classes, Intent } from '@blueprintjs/core'; +import styled from 'styled-components'; +import { FFormGroup, FSwitch } from '@/components'; +import { MailNotificationForm } from '@/containers/SendMailNotification'; +import { saveInvoke } from '@/utils'; +import { useInvoiceMailDialogBoot } from './InvoiceMailDialogBoot'; + +interface SendMailNotificationFormProps { + onClose?: () => void; +} + +export function InvoiceMailDialogFormContent({ + onClose, +}: SendMailNotificationFormProps) { + const { isSubmitting } = useFormikContext(); + const { mailOptions } = useInvoiceMailDialogBoot(); + + const handleClose = () => { + saveInvoke(onClose); + }; + + return ( +
+
+ + + + +
+ +
+
+ + + +
+
+
+ ); +} + +const AttachFormGroup = styled(FFormGroup)` + background: #f8f9fb; + margin-top: 0.6rem; + padding: 4px 14px; + border-radius: 5px; + border: 1px solid #dcdcdd; +`; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/index.ts b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/index.ts new file mode 100644 index 000000000..b40bce27b --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/index.ts @@ -0,0 +1,2 @@ +export * from './InvoiceMailDialog'; +export * from './InvoiceMailDialogContent'; \ No newline at end of file diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceUniversalSearch.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceUniversalSearch.tsx index 84a8858e4..a86141612 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceUniversalSearch.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceUniversalSearch.tsx @@ -83,13 +83,13 @@ export function InvoiceUniversalSearchItem( {highlightText(item.reference.invoice_no, query)}{' '} - {item.reference.formatted_invoice_date} + {item.reference.invoice_date_formatted} } label={ <> -
${item.reference.balance}
+
{item.reference.total_formatted}
} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/InvoicesDataTable.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/InvoicesDataTable.tsx index c2f5a78df..75ba9b7f6 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/InvoicesDataTable.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/InvoicesDataTable.tsx @@ -26,6 +26,7 @@ import { useInvoicesListContext } from './InvoicesListProvider'; import { compose } from '@/utils'; import { DRAWERS } from '@/constants/drawers'; +import { DialogsName } from '@/constants/dialogs'; /** * Invoices datatable. @@ -98,6 +99,11 @@ function InvoicesDataTable({ openDialog('invoice-pdf-preview', { invoiceId: id }); }; + // Handle send mail invoice. + const handleSendMailInvoice = ({ id }) => { + openDialog(DialogsName.InvoiceMail, { invoiceId: id }); + }; + // Handle cell click. const handleCellClick = (cell, event) => { openDrawer(DRAWERS.INVOICE_DETAILS, { invoiceId: cell.row.original.id }); @@ -157,6 +163,7 @@ function InvoicesDataTable({ onViewDetails: handleViewDetailInvoice, onPrint: handlePrintInvoice, onConvert: handleConvertToCreitNote, + onSendMail: handleSendMailInvoice }} /> diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/components.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/components.tsx index c5ebc3aee..2739ad9e5 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/components.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/components.tsx @@ -128,6 +128,7 @@ export function ActionsMenu({ onQuick, onViewDetails, onPrint, + onSendMail }, row: { original }, }) { @@ -150,7 +151,6 @@ export function ActionsMenu({ text={intl.get('invoice.convert_to_credit_note')} onClick={safeCallback(onConvert, original)} /> - } @@ -169,6 +169,11 @@ export function ActionsMenu({
+ } + text={'Send Mail'} + onClick={safeCallback(onSendMail, original)} + /> } text={intl.get('print')} diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialog.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialog.tsx new file mode 100644 index 000000000..32c175ed9 --- /dev/null +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialog.tsx @@ -0,0 +1,43 @@ +// @ts-nocheck +import React from 'react'; +import { Dialog, DialogSuspense } from '@/components'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import { compose } from '@/utils'; + +const PaymentMailDialogContent = React.lazy( + () => import('./PaymentMailDialogContent'), +); + +/** + * Payment mail dialog. + */ +function PaymentMailDialog({ + dialogName, + payload: { + paymentReceiveId = null, + + // Redirects to the payments list on mail submitting. + redirectToPaymentsList = false, + }, + isOpen, +}) { + return ( + + + + + + ); +} +export default compose(withDialogRedux())(PaymentMailDialog); diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogBoot.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogBoot.tsx new file mode 100644 index 000000000..5fcbd4afa --- /dev/null +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogBoot.tsx @@ -0,0 +1,47 @@ +// @ts-nocheck +import React, { createContext } from 'react'; +import { usePaymentReceiveDefaultOptions } from '@/hooks/query'; +import { DialogContent } from '@/components'; + +interface PaymentMailDialogBootValues { + paymentReceiveId: number; + mailOptions: any; +} + +const PaymentMailDialogBootContext = + createContext(); + +interface PaymentMailDialogBootProps { + paymentReceiveId: number; + redirectToPaymentsList: boolean; + children: React.ReactNode; +} + +/** + * Payment mail dialog boot provider. + */ +function PaymentMailDialogBoot({ + paymentReceiveId, + ...props +}: PaymentMailDialogBootProps) { + const { data: mailOptions, isLoading: isMailOptionsLoading } = + usePaymentReceiveDefaultOptions(paymentReceiveId); + + const provider = { + mailOptions, + isMailOptionsLoading, + paymentReceiveId, + redirectToPaymentsList + }; + + return ( + + + + ); +} + +const usePaymentMailDialogBoot = () => + React.useContext(PaymentMailDialogBootContext); + +export { PaymentMailDialogBoot, usePaymentMailDialogBoot }; diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogContent.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogContent.tsx new file mode 100644 index 000000000..33597cfa9 --- /dev/null +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogContent.tsx @@ -0,0 +1,22 @@ +import { PaymentMailDialogBoot } from './PaymentMailDialogBoot'; +import { PaymentMailDialogForm } from './PaymentMailDialogForm'; + +interface PaymentMailDialogContentProps { + dialogName: string; + paymentReceiveId: number; + redirectToPaymentsList: boolean; +} +export default function PaymentMailDialogContent({ + dialogName, + paymentReceiveId, + redirectToPaymentsList, +}: PaymentMailDialogContentProps) { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx new file mode 100644 index 000000000..f397a8740 --- /dev/null +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx @@ -0,0 +1,87 @@ +// @ts-nocheck +import { Formik, FormikBag } from 'formik'; +import * as R from 'ramda'; +import { Intent } from '@blueprintjs/core'; +import { usePaymentMailDialogBoot } from './PaymentMailDialogBoot'; +import { DialogsName } from '@/constants/dialogs'; +import { useSendPaymentReceiveMail } from '@/hooks/query'; +import { PaymentMailDialogFormContent } from './PaymentMailDialogFormContent'; +import { + MailNotificationFormValues, + initialMailNotificationValues, + transformMailFormToRequest, + transformMailFormToInitialValues, +} from '@/containers/SendMailNotification/utils'; +import { AppToaster } from '@/components'; +import { useHistory } from 'react-router-dom'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; + +const initialFormValues = { + ...initialMailNotificationValues, + attachPayment: true, +}; + +interface PaymentMailFormValue extends MailNotificationFormValues { + attachPayment: boolean; +} + +export function PaymentMailDialogFormRoot({ + // #withDialogActions + closeDialog, +}) { + const { mailOptions, paymentReceiveId, redirectToPaymentsList } = + usePaymentMailDialogBoot(); + const { mutateAsync: sendPaymentMail } = useSendPaymentReceiveMail(); + + const history = useHistory(); + + const initialValues = transformMailFormToInitialValues( + mailOptions, + initialFormValues, + ); + // Handles the form submitting. + const handleSubmit = ( + values: PaymentMailFormValue, + { setSubmitting }: FormikBag, + ) => { + const reqValues = transformMailFormToRequest(values); + + setSubmitting(true); + sendPaymentMail([paymentReceiveId, reqValues]) + .then(() => { + AppToaster.show({ + message: 'The mail notification has been sent successfully.', + intent: Intent.SUCCESS, + }); + setSubmitting(false); + closeDialog(DialogsName.PaymentMail); + + // Redirects to payments list if the option is enabled. + if (redirectToPaymentsList) { + history.push('/payment-receives'); + } + }) + .catch(() => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + setSubmitting(false); + closeDialog(DialogsName.PaymentMail); + }); + }; + + const handleClose = () => { + closeDialog(DialogsName.PaymentMail); + }; + + return ( + + + + ); +} + +export const PaymentMailDialogForm = R.compose(withDialogActions)( + PaymentMailDialogFormRoot, +); diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogFormContent.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogFormContent.tsx new file mode 100644 index 000000000..5a04f0f28 --- /dev/null +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogFormContent.tsx @@ -0,0 +1,66 @@ +// @ts-nocheck +import { Form, useFormikContext } from 'formik'; +import { Button, Classes, Intent } from '@blueprintjs/core'; +import styled from 'styled-components'; +import { FFormGroup, FSwitch } from '@/components'; +import { MailNotificationForm } from '@/containers/SendMailNotification'; +import { saveInvoke } from '@/utils'; +import { usePaymentMailDialogBoot } from './PaymentMailDialogBoot'; + +interface PaymentMailDialogFormContentProps { + onClose?: () => void; +} + +export function PaymentMailDialogFormContent({ + onClose, +}: PaymentMailDialogFormContentProps) { + const { mailOptions } = usePaymentMailDialogBoot(); + const { isSubmitting } = useFormikContext(); + + const handleClose = () => { + saveInvoke(onClose); + }; + + return ( +
+
+ + + + +
+ +
+
+ + + +
+
+
+ ); +} + +const AttachFormGroup = styled(FFormGroup)` + background: #f8f9fb; + margin-top: 0.6rem; + padding: 4px 14px; + border-radius: 5px; + border: 1px solid #dcdcdd; +`; diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/index.ts b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/index.ts new file mode 100644 index 000000000..5a2fbde70 --- /dev/null +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/index.ts @@ -0,0 +1 @@ +export * from './PaymentMailDialog'; \ No newline at end of file diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentReceiveForm/PaymentReceiveItemsTable.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentReceiveForm/PaymentReceiveItemsTable.tsx index 5b8a1be34..910403c7e 100644 --- a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentReceiveForm/PaymentReceiveItemsTable.tsx +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentReceiveForm/PaymentReceiveItemsTable.tsx @@ -28,6 +28,7 @@ export default function PaymentReceiveItemsTable({ // Formik context. const { values: { customer_id }, + errors, } = useFormikContext(); // No results message. @@ -58,7 +59,7 @@ export default function PaymentReceiveItemsTable({ data={entries} spinnerProps={false} payload={{ - errors: [], + errors: errors?.entries || [], updateData: handleUpdateData, currencyCode, }} diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentsLanding/PaymentReceivesTable.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentsLanding/PaymentReceivesTable.tsx index 15a8346df..82e7f7cb5 100644 --- a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentsLanding/PaymentReceivesTable.tsx +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentsLanding/PaymentReceivesTable.tsx @@ -17,12 +17,14 @@ import withPaymentReceives from './withPaymentReceives'; import withPaymentReceivesActions from './withPaymentReceivesActions'; import withAlertsActions from '@/containers/Alert/withAlertActions'; import withDrawerActions from '@/containers/Drawer/withDrawerActions'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; import withSettings from '@/containers/Settings/withSettings'; import { usePaymentReceivesColumns, ActionsMenu } from './components'; import { usePaymentReceivesListContext } from './PaymentReceiptsListProvider'; import { useMemorizedColumnsWidths } from '@/hooks'; import { DRAWERS } from '@/constants/drawers'; +import { DialogsName } from '@/constants/dialogs'; /** * Payment receives datatable. @@ -31,15 +33,15 @@ function PaymentReceivesDataTable({ // #withPaymentReceivesActions setPaymentReceivesTableState, - // #withPaymentReceives - paymentReceivesTableState, - // #withAlertsActions openAlert, // #withDrawerActions openDrawer, + // #withDialogActions + openDialog, + // #withSettings paymentReceivesTableSize, }) { @@ -73,6 +75,11 @@ function PaymentReceivesDataTable({ openDrawer(DRAWERS.PAYMENT_RECEIVE_DETAILS, { paymentReceiveId: id }); }; + // Handle mail send payment receive. + const handleSendMailPayment = ({ id }) => { + openDialog(DialogsName.PaymentMail, { paymentReceiveId: id }); + }; + // Handle cell click. const handleCellClick = (cell, event) => { openDrawer(DRAWERS.PAYMENT_RECEIVE_DETAILS, { @@ -129,6 +136,7 @@ function PaymentReceivesDataTable({ onDelete: handleDeletePaymentReceive, onEdit: handleEditPaymentReceive, onViewDetails: handleViewDetailPaymentReceive, + onSendMail: handleSendMailPayment, }} /> @@ -139,6 +147,7 @@ export default compose( withPaymentReceivesActions, withAlertsActions, withDrawerActions, + withDialogActions, withPaymentReceives(({ paymentReceivesTableState }) => ({ paymentReceivesTableState, })), diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentsLanding/components.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentsLanding/components.tsx index 4af4f14ba..fd6aec581 100644 --- a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentsLanding/components.tsx +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentsLanding/components.tsx @@ -15,14 +15,17 @@ import { import { FormatDateCell, Money, Icon, Can } from '@/components'; import { safeCallback } from '@/utils'; import { CLASSES } from '@/constants/classes'; -import { PaymentReceiveAction, AbilitySubject } from '@/constants/abilityOption'; +import { + PaymentReceiveAction, + AbilitySubject, +} from '@/constants/abilityOption'; /** * Table actions menu. */ export function ActionsMenu({ row: { original: paymentReceive }, - payload: { onEdit, onDelete, onViewDetails }, + payload: { onEdit, onDelete, onViewDetails, onSendMail }, }) { return ( @@ -31,6 +34,11 @@ export function ActionsMenu({ text={intl.get('view_details')} onClick={safeCallback(onViewDetails, paymentReceive)} /> + } + text={'Send Mail'} + onClick={safeCallback(onSendMail, paymentReceive)} + /> import('./ReceiptFormMailDeliverDialogContent'), +); + +/** + * Receipt mail dialog. + */ +function ReceiptFormMailDeliverDialog({ + dialogName, + payload: { receiptId = null }, + isOpen, +}) { + return ( + + + + + + ); +} + +export default compose(withDialogRedux())(ReceiptFormMailDeliverDialog); diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/Dialogs/ReceiptFormMailDeliverDialogContent.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/Dialogs/ReceiptFormMailDeliverDialogContent.tsx new file mode 100644 index 000000000..4b5d31e40 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/Dialogs/ReceiptFormMailDeliverDialogContent.tsx @@ -0,0 +1,40 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { useHistory } from 'react-router-dom'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import ReceiptMailDialogContent from '../../ReceiptMailDialog/ReceiptMailDialogContent'; +import { DialogsName } from '@/constants/dialogs'; + +interface ReceiptFormDeliverDialogContent { + receiptId: number; +} + +function ReceiptFormDeliverDialogContentRoot({ + receiptId, + + // #withDialogActions + closeDialog, +}: ReceiptFormDeliverDialogContent) { + const history = useHistory(); + + const handleSubmit = () => { + history.push('/receipts'); + closeDialog(DialogsName.ReceiptFormMailDeliver); + }; + const handleCancel = () => { + history.push('/receipts'); + closeDialog(DialogsName.ReceiptFormMailDeliver); + }; + + return ( + + ); +} + +export default R.compose(withDialogActions)( + ReceiptFormDeliverDialogContentRoot, +); diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx index 75a8b9665..990c083ba 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx @@ -1,5 +1,4 @@ // @ts-nocheck -import React, { useMemo } from 'react'; import intl from 'react-intl-universal'; import classNames from 'classnames'; import { Formik, Form } from 'formik'; @@ -35,7 +34,10 @@ import { transformFormValuesToRequest, resetFormState, } from './utils'; -import { ReceiptSyncIncrementSettingsToForm } from './components'; +import { + ReceiptSyncAutoExRateToForm, + ReceiptSyncIncrementSettingsToForm, +} from './components'; /** * Receipt form. @@ -45,6 +47,8 @@ function ReceiptForm({ receiptNextNumber, receiptNumberPrefix, receiptAutoIncrement, + receiptTermsConditions, + receiptMessage, preferredDepositAccount, // #withCurrentOrganization @@ -67,23 +71,21 @@ function ReceiptForm({ receiptNextNumber, ); // Initial values in create and edit mode. - const initialValues = useMemo( - () => ({ - ...(!isEmpty(receipt) - ? { ...transformToEditForm(receipt) } - : { - ...defaultReceipt, - ...(receiptAutoIncrement && { - receipt_number: nextReceiptNumber, - }), - deposit_account_id: parseInt(preferredDepositAccount), - entries: orderingLinesIndexes(defaultReceipt.entries), - currency_code: base_currency, + const initialValues = { + ...(!isEmpty(receipt) + ? { ...transformToEditForm(receipt) } + : { + ...defaultReceipt, + ...(receiptAutoIncrement && { + receipt_number: nextReceiptNumber, }), - }), - [receipt, preferredDepositAccount, nextReceiptNumber, receiptAutoIncrement], - ); - + deposit_account_id: parseInt(preferredDepositAccount), + entries: orderingLinesIndexes(defaultReceipt.entries), + currency_code: base_currency, + receipt_message: receiptMessage, + terms_conditions: receiptTermsConditions, + }), + }; // Handle the form submit. const handleFormSubmit = ( values, @@ -172,6 +174,7 @@ function ReceiptForm({ {/*---------- Effects ---------*/} + @@ -184,6 +187,8 @@ export default compose( receiptNextNumber: receiptSettings?.nextNumber, receiptNumberPrefix: receiptSettings?.numberPrefix, receiptAutoIncrement: receiptSettings?.autoIncrement, + receiptMessage: receiptSettings?.receiptMessage, + receiptTermsConditions: receiptSettings?.termsConditions, preferredDepositAccount: receiptSettings?.preferredDepositAccount, })), withCurrentOrganization(), diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormDialogs.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormDialogs.tsx index 4fe2cb947..30477aee6 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormDialogs.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormDialogs.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { useFormikContext } from 'formik'; import ReceiptNumberDialog from '@/containers/Dialogs/ReceiptNumberDialog'; +import ReceiptFormMailDeliverDialog from './Dialogs/ReceiptFormMailDeliverDialog'; +import { DialogsName } from '@/constants/dialogs'; /** * Receipt form dialogs. @@ -27,6 +29,9 @@ export default function ReceiptFormDialogs() { dialogName={'receipt-number-form'} onConfirm={handleReceiptNumberFormConfirm} /> + ); } diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormHeaderFields.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormHeaderFields.tsx index 92ef297cb..1536c9785 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormHeaderFields.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormHeaderFields.tsx @@ -33,6 +33,7 @@ import { ReceiptProjectSelectButton, } from './components'; import { ReceiptFormReceiptNumberField } from './ReceiptFormReceiptNumberField'; +import { useCustomerUpdateExRate } from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; /** * Receipt form header fields. @@ -46,10 +47,7 @@ export default function ReceiptFormHeader() { {/* ----------- Exchange rate ----------- */} - + {/* ----------- Deposit account ----------- */} { + setFieldValue('customer_id', customer.id); + setFieldValue('currency_code', customer?.currency_code); + + updateEntries(customer); + }; + return ( } - onItemChange={(customer) => { - setFieldValue('customer_id', customer.id); - setFieldValue('currency_code', customer?.currency_code); - }} + onItemChange={handleItemChange} popoverFill={true} allowCreate={true} fastField={true} diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormPage.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormPage.tsx index dddd093ab..da66da72b 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormPage.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormPage.tsx @@ -6,6 +6,7 @@ import '@/style/pages/SaleReceipt/PageForm.scss'; import ReceiptFrom from './ReceiptForm'; import { ReceiptFormProvider } from './ReceiptFormProvider'; +import { AutoExchangeRateProvider } from '@/containers/Entries/AutoExchangeProvider'; /** * Receipt form page. @@ -16,7 +17,9 @@ export default function ReceiptFormPage() { return ( - + + + ); } diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/components.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/components.tsx index 937a4f937..7d47998e3 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/components.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/components.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import React from 'react'; +import React, { useRef } from 'react'; import intl from 'react-intl-universal'; import { Button } from '@blueprintjs/core'; import { useFormikContext } from 'formik'; @@ -7,20 +7,26 @@ import * as R from 'ramda'; import { ExchangeRateInputGroup } from '@/components'; import { useCurrentOrganization } from '@/hooks/state'; -import { useReceiptIsForeignCustomer } from './utils'; +import { useReceiptIsForeignCustomer, useReceiptTotals } from './utils'; import { useUpdateEffect } from '@/hooks'; -import withSettings from '@/containers/Settings/withSettings'; import { transactionNumber } from '@/utils'; +import withSettings from '@/containers/Settings/withSettings'; +import { + useSyncExRateToForm, + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +} from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DialogsName } from '@/constants/dialogs'; /** * Receipt exchange rate input field. * @returns {JSX.Element} */ -export function ReceiptExchangeRateInputField({ ...props }) { +function ReceiptExchangeRateInputFieldRoot({ ...props }) { const currentOrganization = useCurrentOrganization(); - const { values } = useFormikContext(); - const isForeignCustomer = useReceiptIsForeignCustomer(); + const { values } = useFormikContext(); // Can't continue if the customer is not foreign. if (!isForeignCustomer) { @@ -28,13 +34,21 @@ export function ReceiptExchangeRateInputField({ ...props }) { } return ( ); } +export const ReceiptExchangeRateInputField = R.compose( + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +)(ReceiptExchangeRateInputFieldRoot); + /** * Receipt project select. * @returns {JSX.Element} @@ -73,3 +87,31 @@ export const ReceiptSyncIncrementSettingsToForm = R.compose( return null; }); + +/** + * Syncs the auto exchange rate to the receipt form and shows up popup to user + * as an indication the entries rates have been changed. + * @returns {React.ReactNode} + */ +export const ReceiptSyncAutoExRateToForm = R.compose(withDialogActions)( + ({ + // #withDialogActions + openDialog, + }) => { + const { total } = useReceiptTotals(); + const timeout = useRef(); + + useSyncExRateToForm({ + onSynced: () => { + // If the total bigger then zero show alert to the user after adjusting entries. + if (total > 0) { + clearTimeout(timeout.current); + timeout.current = setTimeout(() => { + openDialog(DialogsName.InvoiceExchangeRateChangeNotice); + }, 500); + } + }, + }); + return null; + }, +); diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/utils.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/utils.tsx index e8c019e7a..d58cb6179 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/utils.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/utils.tsx @@ -7,7 +7,6 @@ import { omit, first } from 'lodash'; import { useFormikContext } from 'formik'; import { defaultFastFieldShouldUpdate, - transactionNumber, repeatValue, transformToForm, formattedAmount, @@ -50,7 +49,7 @@ export const defaultReceipt = { receipt_date: moment(new Date()).format('YYYY-MM-DD'), reference_no: '', receipt_message: '', - statement: '', + terms_conditions: '', closed: '', branch_id: '', warehouse_id: '', diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog.tsx new file mode 100644 index 000000000..69a0e64a9 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog.tsx @@ -0,0 +1,34 @@ +// @ts-nocheck +import React from 'react'; +import { Dialog, DialogSuspense } from '@/components'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import { compose } from '@/utils'; + +const ReceiptMailDialogBody = React.lazy( + () => import('./ReceiptMailDialogBody'), +); + +/** + * Receipt mail dialog. + */ +function ReceiptMailDialog({ + dialogName, + payload: { receiptId = null }, + isOpen, +}) { + return ( + + + + + + ); +} +export default compose(withDialogRedux())(ReceiptMailDialog); diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogBody.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogBody.tsx new file mode 100644 index 000000000..fbd379b84 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogBody.tsx @@ -0,0 +1,33 @@ +// @ts-nocheck +import * as R from 'ramda'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import ReceiptMailDialogContent, { + ReceiptMailDialogContentProps, +} from './ReceiptMailDialogContent'; +import { DialogsName } from '@/constants/dialogs'; + +interface ReceiptMailDialogBodyProps extends ReceiptMailDialogContentProps {} + +function ReceiptMailDialogBodyRoot({ + receiptId, + + // #withDialogActions + closeDialog, +}: ReceiptMailDialogBodyProps) { + const handleCancelClick = () => { + closeDialog(DialogsName.ReceiptMail); + }; + const handleSubmitClick = () => { + closeDialog(DialogsName.ReceiptMail); + }; + + return ( + + ); +} + +export default R.compose(withDialogActions)(ReceiptMailDialogBodyRoot); diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogBoot.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogBoot.tsx new file mode 100644 index 000000000..54f7200db --- /dev/null +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogBoot.tsx @@ -0,0 +1,49 @@ +// @ts-nocheck +import React, { createContext } from 'react'; +import { useSaleReceiptDefaultOptions } from '@/hooks/query'; +import { DialogContent } from '@/components'; + +interface ReceiptMailDialogBootValues { + receiptId: number; + mailOptions: any; + redirectToReceiptsList: boolean; +} + +const ReceiptMailDialogBootContext = + createContext(); + +interface ReceiptMailDialogBootProps { + receiptId: number; + children: React.ReactNode; + redirectToReceiptsList?: boolean; +} + +/** + * Receipt mail dialog boot provider. + */ +function ReceiptMailDialogBoot({ + receiptId, + redirectToReceiptsList = false, + ...props +}: ReceiptMailDialogBootProps) { + const { data: mailOptions, isLoading: isMailOptionsLoading } = + useSaleReceiptDefaultOptions(receiptId); + + const provider = { + saleReceiptId: receiptId, + mailOptions, + isMailOptionsLoading, + redirectToReceiptsList, + }; + + return ( + + + + ); +} + +const useReceiptMailDialogBoot = () => + React.useContext(ReceiptMailDialogBootContext); + +export { ReceiptMailDialogBoot, useReceiptMailDialogBoot }; diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogContent.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogContent.tsx new file mode 100644 index 000000000..a02966a1c --- /dev/null +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogContent.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { ReceiptMailDialogBoot } from './ReceiptMailDialogBoot'; +import { ReceiptMailDialogForm } from './ReceiptMailDialogForm'; + +export interface ReceiptMailDialogContentProps { + receiptId: number; + onFormSubmit?: () => void; + onCancelClick?: () => void; +} +export default function ReceiptMailDialogContent({ + receiptId, + onFormSubmit, + onCancelClick +}: ReceiptMailDialogContentProps) { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx new file mode 100644 index 000000000..db2808f4c --- /dev/null +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx @@ -0,0 +1,79 @@ +// @ts-nocheck +import { Formik, FormikBag } from 'formik'; +import * as R from 'ramda'; +import { Intent } from '@blueprintjs/core'; +import { useReceiptMailDialogBoot } from './ReceiptMailDialogBoot'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DialogsName } from '@/constants/dialogs'; +import { useSendSaleReceiptMail } from '@/hooks/query'; +import { ReceiptMailDialogFormContent } from './ReceiptMailDialogFormContent'; +import { + initialMailNotificationValues, + MailNotificationFormValues, + transformMailFormToInitialValues, + transformMailFormToRequest, +} from '@/containers/SendMailNotification/utils'; +import { AppToaster } from '@/components'; + +const initialFormValues = { + ...initialMailNotificationValues, + attachReceipt: true, +}; +interface ReceiptMailFormValues extends MailNotificationFormValues { + attachReceipt: boolean; +} + +interface ReceiptMailDialogFormProps { + onFormSubmit?: () => void; + onCancelClick?: () => void; +} + +export function ReceiptMailDialogForm({ + // #props + onFormSubmit, + onCancelClick, +}: ReceiptMailDialogFormProps) { + const { mailOptions, saleReceiptId } = useReceiptMailDialogBoot(); + const { mutateAsync: sendReceiptMail } = useSendSaleReceiptMail(); + + // Transformes mail options to initial form values. + const initialValues = transformMailFormToInitialValues( + mailOptions, + initialFormValues, + ); + // Handle the form submitting. + const handleSubmit = ( + values: ReceiptMailFormValues, + { setSubmitting }: FormikBag, + ) => { + const reqValues = transformMailFormToRequest(values); + + setSubmitting(true); + sendReceiptMail([saleReceiptId, reqValues]) + .then(() => { + AppToaster.show({ + message: 'The mail notification has been sent successfully.', + intent: Intent.SUCCESS, + }); + setSubmitting(false); + onFormSubmit && onFormSubmit(values); + }) + .catch(() => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + setSubmitting(false); + }); + }; + // Handle the close button click. + const handleClose = () => { + onCancelClick && onCancelClick(); + }; + + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogFormContent.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogFormContent.tsx new file mode 100644 index 000000000..d824d35af --- /dev/null +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogFormContent.tsx @@ -0,0 +1,66 @@ +// @ts-nocheck +import { Form, useFormikContext } from 'formik'; +import { Button, Classes, Intent } from '@blueprintjs/core'; +import styled from 'styled-components'; +import { FFormGroup, FSwitch } from '@/components'; +import { MailNotificationForm } from '@/containers/SendMailNotification'; +import { useReceiptMailDialogBoot } from './ReceiptMailDialogBoot'; +import { saveInvoke } from '@/utils'; + +interface SendMailNotificationFormProps { + onClose?: () => void; +} + +export function ReceiptMailDialogFormContent({ + onClose, +}: SendMailNotificationFormProps) { + const { mailOptions } = useReceiptMailDialogBoot(); + const { isSubmitting } = useFormikContext(); + + const handleClose = () => { + saveInvoke(onClose); + }; + + return ( +
+
+ + + + +
+ +
+
+ + + +
+
+
+ ); +} + +const AttachFormGroup = styled(FFormGroup)` + background: #f8f9fb; + margin-top: 0.6rem; + padding: 4px 14px; + border-radius: 5px; + border: 1px solid #dcdcdd; +`; diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/index.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/index.tsx new file mode 100644 index 000000000..575fb462b --- /dev/null +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/index.tsx @@ -0,0 +1 @@ +export * from './ReceiptMailDialog'; \ No newline at end of file diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptUniversalSearch.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptUniversalSearch.tsx index ec050da8f..abaea4004 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptUniversalSearch.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptUniversalSearch.tsx @@ -75,7 +75,7 @@ export function ReceiptUniversalSearchItem( } label={ <> -
${item.reference.amount}
+
{item.reference.formatted_amount}
} diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptsLanding/ReceiptActionsBar.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptsLanding/ReceiptActionsBar.tsx index 828ff5a70..7517cf104 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptsLanding/ReceiptActionsBar.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptsLanding/ReceiptActionsBar.tsx @@ -140,7 +140,6 @@ function ReceiptActionsBar({ icon={} text={} /> - { + openDialog(DialogsName.ReceiptMail, { receiptId: id }); + }; + // Local storage memorizing columns widths. const [initialColumnsWidths, , handleColumnResizing] = useMemorizedColumnsWidths(TABLES.RECEIPTS); @@ -141,6 +147,7 @@ function ReceiptsDataTable({ onClose: handleCloseReceipt, onViewDetails: handleViewDetailReceipt, onPrint: handlePrintInvoice, + onSendMail: handleSendMailReceipt, }} /> diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptsLanding/components.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptsLanding/components.tsx index 4d843937e..e76b2d9c6 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptsLanding/components.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptsLanding/components.tsx @@ -24,7 +24,7 @@ import { SaleReceiptAction, AbilitySubject } from '@/constants/abilityOption'; * @returns {React.JSX} */ export function ActionsMenu({ - payload: { onEdit, onDelete, onClose, onDrawer, onViewDetails, onPrint }, + payload: { onEdit, onDelete, onClose, onSendMail, onViewDetails, onPrint }, row: { original: receipt }, }) { return ( @@ -51,6 +51,11 @@ export function ActionsMenu({
+ } + text={'Send Mail'} + onClick={safeCallback(onSendMail, receipt)} + /> } text={intl.get('print')} diff --git a/packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx b/packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx new file mode 100644 index 000000000..74712a606 --- /dev/null +++ b/packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx @@ -0,0 +1,130 @@ +// @ts-nocheck +import { + Box, + FFormGroup, + FInputGroup, + FMultiSelect, + FRichEditor, +} from '@/components'; +import styled from 'styled-components'; +import { SelectOptionProps } from '@blueprintjs-formik/select'; + +interface MailNotificationFormProps { + fromAddresses: SelectOptionProps[]; + toAddresses: SelectOptionProps[]; +} + +const commonAddressSelect = { + placeholder: '', + labelAccessor: '', + valueAccessor: 'mail', + tagAccessor: (item) => `<${item.label}> (${item.mail})`, + textAccessor: (item) => `<${item.label}> (${item.mail})`, +}; + +export function MailNotificationForm({ + fromAddresses, + toAddresses, +}: MailNotificationFormProps) { + return ( + + + + + + + + + + + + + + + + + + ); +} + +const MailMessageEditor = styled(FRichEditor)` + padding: 15px; + border: 1px solid #dedfe9; + border-top: 0; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; +`; + +const HeaderBox = styled('div')` + border-top-right-radius: 5px; + border-top-left-radius: 5px; + border: 1px solid #dddfe9; + border-bottom: 2px solid #eaeaef; + padding: 6px 15px; + + .bp4-form-group { + margin: 0; + padding-top: 8px; + padding-bottom: 8px; + + &:not(:last-of-type) { + border-bottom: 1px solid #dddfe9; + } + &:first-of-type { + padding-top: 0; + } + &:last-of-type { + padding-bottom: 0; + } + } + + .bp4-form-content { + flex: 1 0; + } + + .bp4-label { + min-width: 65px; + color: #738091; + } + + .bp4-input { + border-color: transparent; + padding: 0; + + &:focus, + &.bp4-active { + box-shadow: 0 0 0 0; + } + } + + .bp4-input-ghost { + margin-top: 5px; + } + .bp4-tag-input-values { + margin: 0; + } +`; diff --git a/packages/webapp/src/containers/SendMailNotification/index.ts b/packages/webapp/src/containers/SendMailNotification/index.ts new file mode 100644 index 000000000..5662fe7c9 --- /dev/null +++ b/packages/webapp/src/containers/SendMailNotification/index.ts @@ -0,0 +1 @@ +export * from './MailNotificationForm'; \ No newline at end of file diff --git a/packages/webapp/src/containers/SendMailNotification/utils.ts b/packages/webapp/src/containers/SendMailNotification/utils.ts new file mode 100644 index 000000000..59d0f6420 --- /dev/null +++ b/packages/webapp/src/containers/SendMailNotification/utils.ts @@ -0,0 +1,44 @@ +import { castArray, first } from 'lodash'; +import { transformToForm } from '@/utils'; + +export const initialMailNotificationValues = { + from: [], + to: [], + subject: '', + body: '', +}; + +export interface MailNotificationFormValues { + from: string[]; + to: string[]; + subject: string; + body: string; +} + +export const transformMailFormToRequest = ( + values: MailNotificationFormValues, +) => { + return { + ...values, + from: first(values.from), + to: values.to?.join(', '), + }; +}; + +/** + * Transformes the mail options response values to form initial values. + * @param {any} mailOptions + * @param {MailNotificationFormValues} initialValues + * @returns {MailNotificationFormValues} + */ +export const transformMailFormToInitialValues = ( + mailOptions: any, + initialValues: MailNotificationFormValues, +): MailNotificationFormValues => { + return { + ...initialValues, + ...transformToForm(mailOptions, initialValues), + from: mailOptions.from ? castArray(mailOptions.from) : [], + to: mailOptions.to ? castArray(mailOptions.to) : [], + }; +}; diff --git a/packages/webapp/src/containers/UniversalSearch/DashboardUniversalSearch.tsx b/packages/webapp/src/containers/UniversalSearch/DashboardUniversalSearch.tsx index cf67c054b..38cc6a4be 100644 --- a/packages/webapp/src/containers/UniversalSearch/DashboardUniversalSearch.tsx +++ b/packages/webapp/src/containers/UniversalSearch/DashboardUniversalSearch.tsx @@ -11,11 +11,10 @@ import { compose } from '@/utils'; import withUniversalSearchActions from './withUniversalSearchActions'; import withUniversalSearch from './withUniversalSearch'; +import { useGetUniversalSearchTypeOptions } from './utils'; import DashboardUniversalSearchItemActions from './DashboardUniversalSearchItemActions'; import { DashboardUniversalSearchItem } from './components'; - import DashboardUniversalSearchHotkeys from './DashboardUniversalSearchHotkeys'; -import { useGetUniversalSearchTypeOptions } from './utils'; /** * Dashboard universal search. diff --git a/packages/webapp/src/containers/Vendors/VendorForm/VendorForm.schema.tsx b/packages/webapp/src/containers/Vendors/VendorForm/VendorForm.schema.tsx index 6eb0edf71..5e64c61f0 100644 --- a/packages/webapp/src/containers/Vendors/VendorForm/VendorForm.schema.tsx +++ b/packages/webapp/src/containers/Vendors/VendorForm/VendorForm.schema.tsx @@ -22,7 +22,7 @@ const Schema = Yup.object().shape({ billing_address_2: Yup.string().trim(), billing_address_city: Yup.string().trim(), billing_address_state: Yup.string().trim(), - billing_address_postcode: Yup.number().nullable(), + billing_address_postcode: Yup.string().nullable(), billing_address_phone: Yup.number(), shipping_address_country: Yup.string().trim(), @@ -30,7 +30,7 @@ const Schema = Yup.object().shape({ shipping_address_2: Yup.string().trim(), shipping_address_city: Yup.string().trim(), shipping_address_state: Yup.string().trim(), - shipping_address_postcode: Yup.number().nullable(), + shipping_address_postcode: Yup.string().nullable(), shipping_address_phone: Yup.number(), opening_balance: Yup.number().nullable(), diff --git a/packages/webapp/src/containers/Vendors/VendorsUniversalSearch.tsx b/packages/webapp/src/containers/Vendors/VendorsUniversalSearch.tsx index 84ccdfcb7..8134cc25c 100644 --- a/packages/webapp/src/containers/Vendors/VendorsUniversalSearch.tsx +++ b/packages/webapp/src/containers/Vendors/VendorsUniversalSearch.tsx @@ -34,7 +34,7 @@ const VendorUniversalSearchSelectAction = withDrawerActions( const vendorToSearch = (contact) => ({ id: contact.id, text: contact.display_name, - label: contact.balance > 0 ? contact.formatted_balance + '' : '', + label: contact.formatted_balance, reference: contact, }); diff --git a/packages/webapp/src/hooks/query/FinancialReports/index.ts b/packages/webapp/src/hooks/query/FinancialReports/index.ts new file mode 100644 index 000000000..74097ca99 --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/index.ts @@ -0,0 +1,18 @@ +export * from './use-balance-sheet'; +export * from './use-trial-balance-sheet'; +export * from './use-cashflow-sheet'; +export * from './use-profit-loss-sheet'; +export * from './use-general-ledger'; +export * from './use-journal-sheet'; +export * from './use-AP-aging-summary'; +export * from './use-AR-aging-summary'; +export * from './use-inventory-valuation'; +export * from './use-inventory-item-details'; +export * from './use-purchases-by-items'; +export * from './use-sales-by-items'; +export * from './use-customer-balance-summary'; +export * from './use-vendor-balance-summary'; +export * from './use-customer-transactions'; +export * from './use-vendor-transactions'; +export * from './use-sales-tax-liabilities-summary'; +export * from './use-transactions-by-reference'; \ No newline at end of file diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-AP-aging-summary.ts b/packages/webapp/src/hooks/query/FinancialReports/use-AP-aging-summary.ts new file mode 100644 index 000000000..029f2733d --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-AP-aging-summary.ts @@ -0,0 +1,64 @@ +// @ts-nocheck +import { useRequestQuery } from '../../useQueryRequest'; +import { useDownloadFile } from '../../useDownloadFile'; +import { useRequestPdf } from '../../useRequestPdf'; +import t from '../types'; + +/** + * Retrieve A/P aging summary report. + */ +export function useAPAgingSummaryReport(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.AP_AGING_SUMMARY, query], + { + method: 'get', + url: '/financial_statements/payable_aging_summary', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + { + select: (res) => res.data, + ...props, + }, + ); +} + +export const useAPAgingSheetXlsxExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/payable_aging_summary', + config: { + headers: { + accept: 'application/xlsx', + }, + params: query, + }, + filename: 'payable_aging_summary.xlsx', + ...args, + }); +}; + +export const useAPAgingSheetCsvExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/payable_aging_summary', + config: { + headers: { + accept: 'application/csv', + }, + params: query, + }, + filename: 'payable_aging_summary.csv', + ...args, + }); +}; + +/** + * Retrieves the A/P aging summary pdf document. + */ +export function useAPAgingSummaryPdf(query = {}) { + return useRequestPdf({ + url: `/financial_statements/payable_aging_summary`, + params: query, + }); +} diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-AR-aging-summary.ts b/packages/webapp/src/hooks/query/FinancialReports/use-AR-aging-summary.ts new file mode 100644 index 000000000..b2f8b289b --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-AR-aging-summary.ts @@ -0,0 +1,63 @@ +// @ts-nocheck +import { useRequestQuery } from '../../useQueryRequest'; +import { useDownloadFile } from '../../useDownloadFile'; +import { useRequestPdf } from '../../useRequestPdf'; +import t from '../types'; +/** + * Retrieve A/R aging summary report. + */ +export function useARAgingSummaryReport(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.AR_AGING_SUMMARY, query], + { + method: 'get', + url: '/financial_statements/receivable_aging_summary', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + { + select: (res) => res.data, + ...props, + }, + ); +} + +export const useARAgingSheetXlsxExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/receivable_aging_summary', + config: { + headers: { + accept: 'application/xlsx', + }, + params: query, + }, + filename: 'receivable_aging_summary.xlsx', + ...args, + }); +}; + +export const useARAgingSheetCsvExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/receivable_aging_summary', + config: { + headers: { + accept: 'application/csv', + }, + params: query, + }, + filename: 'receivable_aging_summary.csv', + ...args, + }); +}; + +/** + * Retrieves the A/R aging summary pdf document data. + */ +export function useARAgingSummaryPdf(query = {}) { + return useRequestPdf({ + url: `/financial_statements/receivable_aging_summary`, + params: query, + }); +} diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-balance-sheet.ts b/packages/webapp/src/hooks/query/FinancialReports/use-balance-sheet.ts new file mode 100644 index 000000000..ed60756e5 --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-balance-sheet.ts @@ -0,0 +1,82 @@ +// @ts-nocheck + +import { useRequestQuery } from '../../useQueryRequest'; +import { useDownloadFile } from '../../useDownloadFile'; +import { useRequestPdf } from '../../useRequestPdf'; +import t from '../types'; + +/** + * Fetches balance sheet data. + * @param {Object} query - The query parameters for the request. + * @param {Object} props - Additional options for the request. + * @returns {Object} The response object from the useRequestQuery hook. + */ +export function useBalanceSheet(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.BALANCE_SHEET, query], + { + method: 'get', + url: '/financial_statements/balance_sheet', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + { + select: (res) => res.data, + ...props, + }, + ); +} + +/** + * Initiates a download of the balance sheet in XLSX format. + * @param {Object} query - The query parameters for the request. + * @param {Object} args - Additional configurations for the download. + * @returns {Function} A function to trigger the file download. + */ +export const useBalanceSheetXlsxExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/balance_sheet', + config: { + headers: { + accept: 'application/xlsx', + }, + params: query, + }, + filename: 'balance_sheet.xlsx', + ...args, + }); +}; + +/** + * Initiates a download of the balance sheet in CSV format. + * @param {Object} query - The query parameters for the request. + * @param {Object} args - Additional configurations for the download. + * @returns {Function} A function to trigger the file download. + */ +export const useBalanceSheetCsvExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/balance_sheet', + config: { + headers: { + accept: 'application/csv', + }, + params: query, + }, + filename: 'balance_sheet.csv', + ...args, + }); +}; + +/** + * Fetches balance sheet data in PDF format. + * @param {Object} [query={}] - The query parameters for the request. + * @returns {Object} The response object from the useRequestPdf hook. + */ +export function useBalanceSheetPdf(query = {}) { + return useRequestPdf({ + url: `/financial_statements/balance_sheet`, + params: query, + }); +} diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-cashflow-sheet.ts b/packages/webapp/src/hooks/query/FinancialReports/use-cashflow-sheet.ts new file mode 100644 index 000000000..992431ac2 --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-cashflow-sheet.ts @@ -0,0 +1,83 @@ +// @ts-nocheck +import { useRequestQuery } from '../../useQueryRequest'; +import { useDownloadFile } from '../../useDownloadFile'; +import { useRequestPdf } from '../../useRequestPdf'; +import t from '../types'; + +/** + * Retrieve cash flow statement report. + */ +export function useCashFlowStatementReport(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.CASH_FLOW_STATEMENT, query], + { + method: 'get', + url: '/financial_statements/cash-flow', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + { + select: (res) => ({ + columns: res.data.table.columns, + query: res.data.query, + meta: res.data.meta, + tableRows: res.data.table.rows, + }), + defaultData: { + tableRows: [], + columns: [], + query: {}, + meta: {}, + }, + ...props, + }, + ); +} + +export const useCashFlowStatementXlsxExport = (query, args) => { + const url = '/financial_statements/cash-flow'; + const config = { + headers: { + accept: 'application/xlsx', + }, + params: query, + }; + const filename = 'cashflow_statement.xlsx'; + + return useDownloadFile({ + url, + config, + filename, + ...args, + }); +}; + +export const useCashFlowStatementCsvExport = (query, args) => { + const url = '/financial_statements/cash-flow'; + const config = { + headers: { + accept: 'application/csv', + }, + params: query, + }; + const filename = 'cashflow_statement.csv'; + + return useDownloadFile({ + url, + config, + filename, + ...args, + }); +}; + +/** + * Retrieves the cashflow sheet pdf document. + */ +export function useCashflowSheetPdf(query = {}) { + return useRequestPdf({ + url: `/financial_statements/cash-flow`, + params: query, + }); +} diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-customer-balance-summary.ts b/packages/webapp/src/hooks/query/FinancialReports/use-customer-balance-summary.ts new file mode 100644 index 000000000..4e692832e --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-customer-balance-summary.ts @@ -0,0 +1,71 @@ +// @ts-nocheck +import { useRequestQuery } from '../../useQueryRequest'; +import { useDownloadFile } from '../../useDownloadFile'; +import { useRequestPdf } from '../../useRequestPdf'; +import t from '../types'; + +/** + * Retrieve customers balance summary report. + */ +export function useCustomerBalanceSummaryReport(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.CUSTOMERS_BALANCE_SUMMARY, query], + { + method: 'get', + url: '/financial_statements/customer-balance-summary', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + { + select: (res) => ({ + query: res.data.query, + table: res.data.table, + }), + defaultData: { + table: {}, + query: {}, + }, + ...props, + }, + ); +} + +export const useCustomerBalanceSummaryXlsxExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/customer-balance-summary', + config: { + headers: { + accept: 'application/xlsx', + }, + params: query, + }, + filename: 'customer_balance_summary.xlsx', + ...args, + }); +}; + +export const useCustomerBalanceSummaryCsvExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/customer-balance-summary', + config: { + headers: { + accept: 'application/csv', + }, + params: query, + }, + filename: 'customer_balance_summary.csv', + ...args, + }); +}; + +/** + * Retrieves the pdf content of customers balance summary. + */ +export function useCustomerBalanceSummaryPdf(query = {}) { + return useRequestPdf({ + url: `/financial_statements/customer-balance-summary`, + params: query, + }); +} diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-customer-transactions.ts b/packages/webapp/src/hooks/query/FinancialReports/use-customer-transactions.ts new file mode 100644 index 000000000..fd6925fff --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-customer-transactions.ts @@ -0,0 +1,75 @@ +// @ts-nocheck +import { useRequestQuery } from '../../useQueryRequest'; +import { useDownloadFile } from '../../useDownloadFile'; +import { useRequestPdf } from '../../useRequestPdf'; +import t from '../types'; + +/** + * Retrieve customers transactions report. + */ +export function useCustomersTransactionsReport(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.CUSTOMERS_TRANSACTIONS, query], + { + method: 'get', + url: '/financial_statements/transactions-by-customers', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + { + select: (res) => ({ + data: res.data.table, + tableRows: res.data.table.rows, + }), + defaultData: { + tableRows: [], + data: [], + }, + ...props, + }, + ); +} + +export const useCustomersTransactionsXlsxExport = (query, args) => { + const url = '/financial_statements/transactions-by-customers'; + const config = { + headers: { + accept: 'application/xlsx', + }, + params: query, + }; + const filename = 'customers_transactions.xlsx'; + + return useDownloadFile({ + url, + config, + filename, + ...args, + }); +}; + +export const useCustomersTransactionsCsvExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/transactions-by-customers', + config: { + headers: { + accept: 'application/csv', + }, + params: query, + }, + filename: 'customers_transactions.csv', + ...args, + }); +}; + +/** + * Retrieves the pdf content of customers transactions. + */ +export const useCustomersTransactionsPdfExport = (query = {}) => { + return useRequestPdf({ + url: '/financial_statements/transactions-by-customers', + params: query, + }); +}; diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-general-ledger.ts b/packages/webapp/src/hooks/query/FinancialReports/use-general-ledger.ts new file mode 100644 index 000000000..8f03928b8 --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-general-ledger.ts @@ -0,0 +1,63 @@ +// @ts-nocheck +import { useRequestQuery } from '../../useQueryRequest'; +import { useDownloadFile } from '../../useDownloadFile'; +import { useRequestPdf } from '../../useRequestPdf'; +import t from '../types'; + +/** + * Retrieve general ledger (GL) sheet. + */ +export function useGeneralLedgerSheet(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.GENERAL_LEDGER, query], + { + method: 'get', + url: '/financial_statements/general_ledger', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + { + select: (res) => res.data, + ...props, + }, + ); +} +export const useGeneralLedgerSheetXlsxExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/general_ledger', + config: { + headers: { + accept: 'application/xlsx', + }, + params: query, + }, + filename: 'general_ledger.xlsx', + ...args, + }); +}; + +export const useGeneralLedgerSheetCsvExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/general_ledger', + config: { + headers: { + accept: 'application/csv', + }, + params: query, + }, + filename: 'general_ledger.csv', + ...args, + }); +}; + +/** + * Retrieves the general ledger pdf document data. + */ +export function useGeneralLedgerPdf(query = {}) { + return useRequestPdf({ + url: `/financial_statements/general_ledger`, + params: query + }); +} diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-inventory-item-details.ts b/packages/webapp/src/hooks/query/FinancialReports/use-inventory-item-details.ts new file mode 100644 index 000000000..38f0c92d0 --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-inventory-item-details.ts @@ -0,0 +1,75 @@ +// @ts-nocheck +import { useRequestQuery } from '../../useQueryRequest'; +import { useDownloadFile } from '../../useDownloadFile'; +import { useRequestPdf } from '../../useRequestPdf'; +import t from '../types'; + +/** + * Retrieve inventory item detail report. + */ +export function useInventoryItemDetailsReport(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.INVENTORY_ITEM_DETAILS, query], + { + method: 'get', + url: '/financial_statements/inventory-item-details', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + { + select: (res) => ({ + columns: res.data.table.columns, + query: res.data.query, + meta: res.data.meta, + tableRows: res.data.table.rows, + }), + defaultData: { + tableRows: [], + columns: [], + query: {}, + meta: {}, + }, + ...props, + }, + ); +} + +export const useInventoryItemDetailsXlsxExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/inventory-item-details', + config: { + headers: { + accept: 'application/xlsx', + }, + params: query, + }, + filename: 'inventory_item_details.xlsx', + ...args, + }); +}; + +export const useInventoryItemDetailsCsvExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/inventory-item-details', + config: { + headers: { + accept: 'application/csv', + }, + params: query, + }, + filename: 'inventory_item_details.csv', + ...args, + }); +}; + +/** + * Retrieves the balance sheet pdf document data. + */ +export function useInventoryItemDetailsPdf(query = {}) { + return useRequestPdf({ + url: `/financial_statements/inventory-item-details`, + params: query, + }); +} diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-inventory-valuation.ts b/packages/webapp/src/hooks/query/FinancialReports/use-inventory-valuation.ts new file mode 100644 index 000000000..43c39885c --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-inventory-valuation.ts @@ -0,0 +1,83 @@ +// @ts-nocheck +import { useRequestQuery } from '../../useQueryRequest'; +import { useDownloadFile } from '../../useDownloadFile'; +import { useRequestPdf } from '../../useRequestPdf'; +import t from '../types'; + +/** + * Retrieve inventory valuation. + */ +export function useInventoryValuation(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.INVENTORY_VALUATION, query], + { + method: 'get', + url: '/financial_statements/inventory-valuation', + params: query, + }, + { + select: (res) => res.data, + + ...props, + }, + ); +} + +/** + * Retrieve inventory valuation. + */ +export function useInventoryValuationTable(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.INVENTORY_VALUATION, query], + { + method: 'get', + url: '/financial_statements/inventory-valuation', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + { + select: (res) => res.data, + ...props, + }, + ); +} + +export const useInventoryValuationXlsxExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/inventory-valuation', + config: { + headers: { + accept: 'application/xlsx', + }, + params: query, + }, + filename: 'inventory_valuation.xlsx', + ...args, + }); +}; + +export const useInventoryValuationCsvExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/inventory-valuation', + config: { + headers: { + accept: 'application/csv', + }, + params: query, + }, + filename: 'inventory_valuation.csv', + ...args, + }); +}; + +/** + * Retrieves the inventory valuation pdf document data. + */ +export function useInventoryValuationPdf(query = {}) { + return useRequestPdf({ + url: `/financial_statements/inventory-valuation`, + params: query, + }); +} diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-journal-sheet.ts b/packages/webapp/src/hooks/query/FinancialReports/use-journal-sheet.ts new file mode 100644 index 000000000..90c6cb447 --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-journal-sheet.ts @@ -0,0 +1,64 @@ +// @ts-nocheck +import { useRequestQuery } from '../../useQueryRequest'; +import { useDownloadFile } from '../../useDownloadFile'; +import { useRequestPdf } from '../../useRequestPdf'; +import t from '../types'; + +/** + * Retrieve journal sheet. + */ +export function useJournalSheet(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.JOURNAL, query], + { + method: 'get', + url: '/financial_statements/journal', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + { + select: (res) => res.data, + ...props, + }, + ); +} + +export const useJournalSheetXlsxExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/journal', + config: { + headers: { + accept: 'application/xlsx', + }, + params: query, + }, + filename: 'journal.xlsx', + ...args, + }); +}; + +export const useJournalSheetCsvExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/journal', + config: { + headers: { + accept: 'application/csv', + }, + params: query, + }, + filename: 'journal.csv', + ...args, + }); +}; + +/** + * Retrieves the journal sheet pdf content. + */ +export const useJournalSheetPdf = (query = {}) => { + return useRequestPdf({ + url: `/financial_statements/journal`, + params: query, + }); +}; diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-profit-loss-sheet.ts b/packages/webapp/src/hooks/query/FinancialReports/use-profit-loss-sheet.ts new file mode 100644 index 000000000..b254e4ba2 --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-profit-loss-sheet.ts @@ -0,0 +1,64 @@ +// @ts-nocheck +import { useRequestQuery } from '../../useQueryRequest'; +import { useDownloadFile } from '../../useDownloadFile'; +import { useRequestPdf } from '../../useRequestPdf'; +import t from '../types'; + +/** + * Retrieve profit/loss (P&L) sheet. + */ +export function useProfitLossSheet(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.PROFIT_LOSS_SHEET, query], + { + method: 'get', + url: '/financial_statements/profit_loss_sheet', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + { + select: (res) => res.data, + ...props, + }, + ); +} + +export const useProfitLossSheetXlsxExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/profit_loss_sheet', + config: { + headers: { + accept: 'application/xlsx', + }, + params: query, + }, + filename: 'profit_loss_sheet.xlsx', + ...args, + }); +}; + +export const useProfitLossSheetCsvExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/profit_loss_sheet', + config: { + headers: { + accept: 'application/csv', + }, + params: query, + }, + filename: 'profit_loss_sheet.csv', + ...args, + }); +}; + +/** + * Retrieves the profit/loss sheet pdf document data. + */ +export function useProfitLossSheetPdf(query = {}) { + return useRequestPdf({ + url: `/financial_statements/profit_loss_sheet`, + params: query, + }); +} diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-purchases-by-items.ts b/packages/webapp/src/hooks/query/FinancialReports/use-purchases-by-items.ts new file mode 100644 index 000000000..882aaf578 --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-purchases-by-items.ts @@ -0,0 +1,79 @@ +// @ts-nocheck +import { useRequestQuery } from '../../useQueryRequest'; +import { useDownloadFile } from '../../useDownloadFile'; +import { useRequestPdf } from '../../useRequestPdf'; +import t from '../types'; + +/** + * Retrieve purchases by items. + */ +export function usePurchasesByItems(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.PURCHASES_BY_ITEMS, query], + { + method: 'get', + url: '/financial_statements/purchases-by-items', + params: query, + }, + { + select: (res) => res.data, + ...props, + }, + ); +} + +export function usePurchasesByItemsTable(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.PURCHASES_BY_ITEMS, query], + { + method: 'get', + url: '/financial_statements/purchases-by-items', + params: query, + headers: { + accept: 'application/json+table', + }, + }, + { + select: (res) => res.data, + ...props, + }, + ); +} + +export const usePurchasesByItemsCsvExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/purchases-by-items', + config: { + headers: { + accept: 'application/csv', + }, + params: query, + }, + filename: 'purchases_by_items.csv', + ...args, + }); +}; + +export const usePurchasesByItemsXlsxExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/purchases-by-items', + config: { + headers: { + accept: 'application/xlsx', + }, + params: query, + }, + filename: 'purchases_by_items.xlsx', + ...args, + }); +}; + +/** + * Retrieves the pdf document of purchases by items. + */ +export const usePurchasesByItemsPdfExport = (query = {}) => { + return useRequestPdf({ + url: '/financial_statements/purchases-by-items', + params: query, + }); +}; diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-sales-by-items.ts b/packages/webapp/src/hooks/query/FinancialReports/use-sales-by-items.ts new file mode 100644 index 000000000..c4feda108 --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-sales-by-items.ts @@ -0,0 +1,78 @@ +// @ts-nocheck +import { useRequestQuery } from '../../useQueryRequest'; +import { useDownloadFile } from '../../useDownloadFile'; +import { useRequestPdf } from '../../useRequestPdf'; +import t from '../types'; + +/** + * Retrieve sales by items. + */ +export function useSalesByItems(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.SALES_BY_ITEMS, query], + { + method: 'get', + url: '/financial_statements/sales-by-items', + params: query, + }, + { + ...props, + }, + ); +} + +/** + * Retrieves sales by items table format. + */ +export function useSalesByItemsTable(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.SALES_BY_ITEMS, query], + { + method: 'get', + url: '/financial_statements/sales-by-items', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + { + select: (res) => res.data, + ...props, + }, + ); +} + +export const useSalesByItemsCsvExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/sales-by-items', + config: { + headers: { + accept: 'application/csv', + }, + params: query, + }, + filename: 'sales_by_items.csv', + ...args, + }); +}; + +export const useSalesByItemsXlsxExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/sales-by-items', + config: { + headers: { + accept: 'application/xlsx', + }, + params: query, + }, + filename: 'sales_by_items.xlsx', + ...args, + }); +}; + +export const useSalesByItemsPdfExport = (query = {}) => { + return useRequestPdf({ + url: '/financial_statements/sales-by-items', + params: query, + }); +}; diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-sales-tax-liabilities-summary.ts b/packages/webapp/src/hooks/query/FinancialReports/use-sales-tax-liabilities-summary.ts new file mode 100644 index 000000000..85cf0eda5 --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-sales-tax-liabilities-summary.ts @@ -0,0 +1,64 @@ +// @ts-nocheck +import { useRequestQuery } from '../../useQueryRequest'; +import { useDownloadFile } from '../../useDownloadFile'; +import { useRequestPdf } from '../../useRequestPdf'; +import t from '../types'; + +/** + * Retrieves the sales tax liability summary report. + */ +export function useSalesTaxLiabilitySummary(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.SALES_TAX_LIABILITY_SUMMARY, query], + { + method: 'get', + url: '/financial_statements/sales-tax-liability-summary', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + { + select: (res) => res.data, + ...props, + }, + ); +} + +export const useSalesTaxLiabilitySummaryXlsxExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/sales-tax-liability-summary', + config: { + headers: { + accept: 'application/xlsx', + }, + params: query, + }, + filename: 'sales_tax_liability_summary.xlsx', + ...args, + }); +}; + +export const useSalesTaxLiabilitySummaryCsvExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/sales-tax-liability-summary', + config: { + headers: { + accept: 'application/csv', + }, + params: query, + }, + filename: 'sales_tax_liability_summary.csv', + ...args, + }); +}; + +/** + * Retrieves pdf document data of sales tax liability summary. + */ +export function useSalesTaxLiabilitySummaryPdf(query = {}) { + return useRequestPdf({ + url: `/financial_statements/sales-tax-liability-summary`, + params: query, + }); +} diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-transactions-by-reference.ts b/packages/webapp/src/hooks/query/FinancialReports/use-transactions-by-reference.ts new file mode 100644 index 000000000..680e98a5a --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-transactions-by-reference.ts @@ -0,0 +1,23 @@ +// @ts-nocheck +import { useRequestQuery } from '../../useQueryRequest'; +import t from '../types'; +/** + * Retrieve transactions by reference report. + */ +export function useTransactionsByReference(query, props) { + return useRequestQuery( + [t.TRANSACTIONS_BY_REFERENCE, query], + { + method: 'get', + url: `/financial_statements/transactions-by-reference`, + params: query, + }, + { + select: (res) => res.data, + defaultData: { + transactions: [], + }, + ...props, + }, + ); +} diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-trial-balance-sheet.ts b/packages/webapp/src/hooks/query/FinancialReports/use-trial-balance-sheet.ts new file mode 100644 index 000000000..96afe488c --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-trial-balance-sheet.ts @@ -0,0 +1,64 @@ +// @ts-nocheck +import { useRequestQuery } from '../../useQueryRequest'; +import { useDownloadFile } from '../../useDownloadFile'; +import { useRequestPdf } from '../../useRequestPdf'; +import t from '../types'; + +/** + * Retrieve trial balance sheet. + */ +export function useTrialBalanceSheet(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.TRIAL_BALANCE_SHEET, query], + { + method: 'get', + url: '/financial_statements/trial_balance_sheet', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + { + select: (res) => res.data, + ...props, + }, + ); +} + +export const useTrialBalanceSheetXlsxExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/trial_balance_sheet', + config: { + headers: { + accept: 'application/xlsx', + }, + params: query, + }, + filename: 'trial_balance_sheet.xlsx', + ...args, + }); +}; + +export const useTrialBalanceSheetCsvExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/trial_balance_sheet', + config: { + headers: { + accept: 'application/csv', + }, + params: query, + }, + filename: 'trial_balance_sheet.csv', + ...args, + }); +}; + +/** + * Retrieves the trial balance sheet pdf document data. + */ +export function useTrialBalanceSheetPdf(query = {}) { + return useRequestPdf({ + url: `/financial_statements/trial_balance_sheet`, + params: query, + }); +} diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-vendor-balance-summary.ts b/packages/webapp/src/hooks/query/FinancialReports/use-vendor-balance-summary.ts new file mode 100644 index 000000000..840a5c08a --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-vendor-balance-summary.ts @@ -0,0 +1,71 @@ +// @ts-nocheck +import { useRequestQuery } from '../../useQueryRequest'; +import { useDownloadFile } from '../../useDownloadFile'; +import { useRequestPdf } from '../../useRequestPdf'; +import t from '../types'; + +/** + * Retrieve vendors balance summary report. + */ +export function useVendorsBalanceSummaryReport(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.VENDORS_BALANCE_SUMMARY, query], + { + method: 'get', + url: '/financial_statements/vendor-balance-summary', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + + { + select: (res) => ({ + query: res.data.query, + table: res.data.table, + }), + defaultData: { + table: {}, + query: {}, + }, + ...props, + }, + ); +} + +export const useVendorBalanceSummaryXlsxExport = (args) => { + const url = '/financial_statements/vendor-balance-summary'; + const config = { + headers: { + accept: 'application/xlsx', + }, + }; + const filename = 'vendor_balance_summary.xlsx'; + + return useDownloadFile({ + url, + config, + filename, + ...args, + }); +}; + +export const useVendorBalanceSummaryCsvExport = (args) => { + return useDownloadFile({ + url: '/financial_statements/vendor-balance-summary', + config: { + headers: { + accept: 'application/csv', + }, + }, + filename: 'vendor_balance_summary.csv', + ...args, + }); +}; + +export const useVendorBalanceSummaryPdfExport = (query = {}) => { + return useRequestPdf({ + url: 'financial_statements/vendor-balance-summary', + params: query, + }); +}; diff --git a/packages/webapp/src/hooks/query/FinancialReports/use-vendor-transactions.ts b/packages/webapp/src/hooks/query/FinancialReports/use-vendor-transactions.ts new file mode 100644 index 000000000..e5768539e --- /dev/null +++ b/packages/webapp/src/hooks/query/FinancialReports/use-vendor-transactions.ts @@ -0,0 +1,67 @@ +// @ts-nocheck +import { useRequestQuery } from '../../useQueryRequest'; +import { useDownloadFile } from '../../useDownloadFile'; +import { useRequestPdf } from '../../useRequestPdf'; +import t from '../types'; + +/** + * Retrieve vendors transactions report. + */ +export function useVendorsTransactionsReport(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.VENDORS_TRANSACTIONS, query], + { + method: 'get', + url: '/financial_statements/transactions-by-vendors', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + { + select: (res) => res.data, + ...props, + }, + ); +} + +export const useVendorsTransactionsXlsxExport = (query, args) => { + const url = '/financial_statements/transactions-by-vendors'; + const config = { + headers: { + accept: 'application/xlsx', + }, + params: query, + }; + const filename = 'transactions_by_vendor.xlsx'; + + return useDownloadFile({ + url, + config, + filename, + ...args, + }); +}; + +export const useVendorsTransactionsCsvExport = (query, args) => { + return useDownloadFile({ + url: '/financial_statements/transactions-by-vendors', + config: { + headers: { + accept: 'application/csv', + }, + params: query, + }, + filename: 'transactions_by_vendor.csv', + ...args, + }); +}; +/** + * Retrieves pdf document data of the transactions by vendor sheet. + */ +export function useTransactionsByVendorsPdf(query = {}) { + return useRequestPdf({ + url: `financial_statements/transactions-by-vendors`, + params: query, + }); +} diff --git a/packages/webapp/src/hooks/query/bills.tsx b/packages/webapp/src/hooks/query/bills.tsx index 828621d89..1ec3ef0e1 100644 --- a/packages/webapp/src/hooks/query/bills.tsx +++ b/packages/webapp/src/hooks/query/bills.tsx @@ -32,6 +32,9 @@ const commonInvalidateQueries = (queryClient) => { // Invalidate financial reports. queryClient.invalidateQueries(t.FINANCIAL_REPORT); + // Invalidate the transactions by reference. + queryClient.invalidateQueries(t.TRANSACTIONS_BY_REFERENCE); + // Invalidate items associated bills transactions. queryClient.invalidateQueries(t.ITEMS_ASSOCIATED_WITH_BILLS); diff --git a/packages/webapp/src/hooks/query/cashflowAccounts.tsx b/packages/webapp/src/hooks/query/cashflowAccounts.tsx index f91593762..a44200961 100644 --- a/packages/webapp/src/hooks/query/cashflowAccounts.tsx +++ b/packages/webapp/src/hooks/query/cashflowAccounts.tsx @@ -5,6 +5,9 @@ import useApiRequest from '../useRequest'; import t from './types'; const commonInvalidateQueries = (queryClient) => { + // Invalidate settings. + queryClient.invalidateQueries([t.SETTING, t.SETTING_CASHFLOW]); + // Invalidate accounts. queryClient.invalidateQueries(t.ACCOUNTS); queryClient.invalidateQueries(t.ACCOUNT); @@ -101,8 +104,8 @@ export function useDeleteCashflowTransaction(props) { export function useAccountTransactionsInfinity( accountId, query, - axios, infinityProps, + axios, ) { const apiRequest = useApiRequest(); @@ -131,6 +134,45 @@ export function useAccountTransactionsInfinity( ); } +/** + * Retrieve account transactions infinity scrolling. + * @param {number} accountId + * @param {*} axios + * @returns + */ +export function useAccountUncategorizedTransactionsInfinity( + accountId, + query, + infinityProps, + axios, +) { + const apiRequest = useApiRequest(); + + return useInfiniteQuery( + [t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, accountId], + async ({ pageParam = 1 }) => { + const response = await apiRequest.http({ + ...axios, + method: 'get', + url: `/api/cashflow/transactions/${accountId}/uncategorized`, + params: { page: pageParam, ...query }, + }); + return response.data; + }, + { + getPreviousPageParam: (firstPage) => firstPage.pagination.page - 1, + getNextPageParam: (lastPage) => { + const { pagination } = lastPage; + + return pagination.total > pagination.page_size * pagination.page + ? lastPage.pagination.page + 1 + : undefined; + }, + ...infinityProps, + }, + ); +} + /** * Refresh cashflow transactions infinity. */ @@ -169,3 +211,71 @@ export function useRefreshCashflowTransactions() { }, }; } + +/** + * Retrieves specific uncategorized transaction. + * @param {number} uncategorizedTranasctionId - + */ +export function useUncategorizedTransaction( + uncategorizedTranasctionId: nunber, + props, +) { + return useRequestQuery( + [t.CASHFLOW_UNCAATEGORIZED_TRANSACTION, uncategorizedTranasctionId], + { + method: 'get', + url: `cashflow/transactions/uncategorized/${uncategorizedTranasctionId}`, + }, + { + select: (res) => res.data?.data, + ...props, + }, + ); +} + +/** + * Categorize the cashflow transaction. + */ +export function useCategorizeTransaction(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + ([id, values]) => + apiRequest.post(`cashflow/transactions/${id}/categorize`, values), + { + onSuccess: (res, id) => { + // Invalidate queries. + commonInvalidateQueries(queryClient); + queryClient.invalidateQueries(t.CASHFLOW_UNCAATEGORIZED_TRANSACTION); + queryClient.invalidateQueries( + t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, + ); + }, + ...props, + }, + ); +} + +/** + * Uncategorize the cashflow transaction. + */ +export function useUncategorizeTransaction(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + (id: number) => apiRequest.post(`cashflow/transactions/${id}/uncategorize`), + { + onSuccess: (res, id) => { + // Invalidate queries. + commonInvalidateQueries(queryClient); + queryClient.invalidateQueries(t.CASHFLOW_UNCAATEGORIZED_TRANSACTION); + queryClient.invalidateQueries( + t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, + ); + }, + ...props, + }, + ); +} diff --git a/packages/webapp/src/hooks/query/creditNote.tsx b/packages/webapp/src/hooks/query/creditNote.tsx index 99cfd535d..fce2f7302 100644 --- a/packages/webapp/src/hooks/query/creditNote.tsx +++ b/packages/webapp/src/hooks/query/creditNote.tsx @@ -44,6 +44,9 @@ const commonInvalidateQueries = (queryClient) => { // Invalidate financial reports. queryClient.invalidateQueries(t.FINANCIAL_REPORT); + // Invalidate transactions by reference. + queryClient.invalidateQueries(t.TRANSACTIONS_BY_REFERENCE); + // Invalidate mutate base currency abilities. queryClient.invalidateQueries(t.ORGANIZATION_MUTATE_BASE_CURRENCY_ABILITIES); }; @@ -351,5 +354,7 @@ export function useRefundCreditTransaction(id, props, requestProps) { * Retrieve the credit note pdf document data, */ export function usePdfCreditNote(creditNoteId) { - return useRequestPdf(`sales/credit_notes/${creditNoteId}`); + return useRequestPdf({ + url: `sales/credit_notes/${creditNoteId}`, + }); } diff --git a/packages/webapp/src/hooks/query/estimates.tsx b/packages/webapp/src/hooks/query/estimates.tsx index 8bb3c2731..ed5f11ef4 100644 --- a/packages/webapp/src/hooks/query/estimates.tsx +++ b/packages/webapp/src/hooks/query/estimates.tsx @@ -181,7 +181,9 @@ export function useRejectEstimate(props) { */ export function usePdfEstimate(estimateId) { - return useRequestPdf(`sales/estimates/${estimateId}`); + return useRequestPdf({ + url: `sales/estimates/${estimateId}`, + }); } export function useRefreshEstimates() { @@ -239,3 +241,33 @@ export function useEstimateSMSDetail(estimateId, props, requestProps) { }, ); } + +export function useSendSaleEstimateMail(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + ([id, values]) => apiRequest.post(`sales/estimates/${id}/mail`, values), + { + onSuccess: (res, [id, values]) => { + // Common invalidate queries. + commonInvalidateQueries(queryClient); + }, + ...props, + }, + ); +} + +export function useSaleEstimateDefaultOptions(estimateId, props) { + return useRequestQuery( + [t.SALE_ESTIMATE_MAIL_OPTIONS, estimateId], + { + method: 'get', + url: `sales/estimates/${estimateId}/mail`, + }, + { + select: (res) => res.data.data, + ...props, + }, + ); +} diff --git a/packages/webapp/src/hooks/query/exchangeRates.tsx b/packages/webapp/src/hooks/query/exchangeRates.tsx index f38b66737..36700276b 100644 --- a/packages/webapp/src/hooks/query/exchangeRates.tsx +++ b/packages/webapp/src/hooks/query/exchangeRates.tsx @@ -1,102 +1,36 @@ // @ts-nocheck -import { useMutation, useQueryClient } from 'react-query'; -import { defaultTo } from 'lodash'; -import { useQueryTenant } from '../useQueryRequest'; -import { transformPagination } from '@/utils'; +import { useQuery } from 'react-query'; +import QUERY_TYPES from './types'; import useApiRequest from '../useRequest'; -const defaultPagination = { - pageSize: 20, - page: 0, - pagesCount: 0, -}; -/** - * Creates a new exchange rate. - */ -export function useCreateExchangeRate(props) { - const queryClient = useQueryClient(); - const apiRequest = useApiRequest(); - - return useMutation((values) => apiRequest.post('exchange_rates', values), { - onSuccess: () => { - queryClient.invalidateQueries('EXCHANGES_RATES'); - }, - ...props, - }); +interface LatestExchangeRateQuery { + fromCurrency?: string; + toCurrency?: string; } /** - * Edits the exchange rate. + * Retrieves latest exchange rate. + * @param {number} customerId - Customer id. */ -export function useEdiExchangeRate(props) { - const queryClient = useQueryClient(); +export function useLatestExchangeRate( + { toCurrency, fromCurrency }: LatestExchangeRateQuery, + props, +) { const apiRequest = useApiRequest(); - return useMutation( - ([id, values]) => apiRequest.post(`exchange_rates/${id}`, values), - { - onSuccess: () => { - queryClient.invalidateQueries('EXCHANGES_RATES'); - }, - ...props, - }, + return useQuery( + [QUERY_TYPES.EXCHANGE_RATE, toCurrency, fromCurrency], + () => + apiRequest + .http({ + url: `/api/exchange_rates/latest`, + method: 'get', + params: { + to_currency: toCurrency, + from_currency: fromCurrency, + }, + }) + .then((res) => res.data), + props, ); } - -/** - * Deletes the exchange rate. - */ -export function useDeleteExchangeRate(props) { - const queryClient = useQueryClient(); - const apiRequest = useApiRequest(); - - return useMutation((id) => apiRequest.delete(`exchange_rates/${id}`), { - onSuccess: () => { - queryClient.invalidateQueries('EXCHANGES_RATES'); - }, - ...props, - }); -} - -/** - * Retrieve the exchange rate list. - */ -export function useExchangeRates(query, props) { - const apiRequest = useApiRequest(); - - const states = useQueryTenant( - ['EXCHANGES_RATES', query], - () => apiRequest.get('exchange_rates', { params: query }), - { - select: (res) => ({ - exchangesRates: res.data.exchange_rates.results, - pagination: transformPagination(res.data.exchange_rates.pagination), - filterMeta: res.data.filter_meta, - }), - ...props, - }, - ); - - return { - ...states, - data: defaultTo(states.data, { - exchangesRates: [], - pagination: { - page: 1, - pageSize: 20, - total: 0, - }, - filterMeta: {}, - }), - }; -} - -export function useRefreshExchangeRate() { - const queryClient = useQueryClient(); - - return { - refresh: () => { - queryClient.invalidateQueries('EXCHANGES_RATES'); - }, - }; -} diff --git a/packages/webapp/src/hooks/query/financialReports.tsx b/packages/webapp/src/hooks/query/financialReports.tsx deleted file mode 100644 index e9531ddec..000000000 --- a/packages/webapp/src/hooks/query/financialReports.tsx +++ /dev/null @@ -1,814 +0,0 @@ -// @ts-nocheck -import { useRequestQuery } from '../useQueryRequest'; -import { - generalLedgerTableRowsReducer, - journalTableRowsReducer, - inventoryValuationReducer, - purchasesByItemsReducer, - salesByItemsReducer, -} from '@/containers/FinancialStatements/reducers'; -import t from './types'; -import { useDownloadFile } from '../useDownloadFile'; - -/** - * Retrieve balance sheet. - */ -export function useBalanceSheet(query, props) { - return useRequestQuery( - [t.FINANCIAL_REPORT, t.BALANCE_SHEET, query], - { - method: 'get', - url: '/financial_statements/balance_sheet', - params: query, - headers: { - Accept: 'application/json+table', - }, - }, - { - select: (res) => res.data, - ...props, - }, - ); -} - -export const useBalanceSheetXlsxExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/balance_sheet', - config: { - headers: { - accept: 'application/xlsx', - }, - params: query, - }, - filename: 'balance_sheet.xlsx', - ...args, - }); -}; - -export const useBalanceSheetCsvExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/balance_sheet', - config: { - headers: { - accept: 'application/csv', - }, - params: query, - }, - filename: 'balance_sheet.csv', - ...args, - }); -}; - -/** - * Retrieve trial balance sheet. - */ -export function useTrialBalanceSheet(query, props) { - return useRequestQuery( - [t.FINANCIAL_REPORT, t.TRIAL_BALANCE_SHEET, query], - { - method: 'get', - url: '/financial_statements/trial_balance_sheet', - params: query, - headers: { - Accept: 'application/json+table', - }, - }, - { - select: (res) => res.data, - ...props, - }, - ); -} - -export const useTrialBalanceSheetXlsxExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/trial_balance_sheet', - config: { - headers: { - accept: 'application/xlsx', - }, - params: query, - }, - filename: 'trial_balance_sheet.xlsx', - ...args, - }); -}; - -export const useTrialBalanceSheetCsvExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/trial_balance_sheet', - config: { - headers: { - accept: 'application/csv', - }, - params: query, - }, - filename: 'trial_balance_sheet.csv', - ...args, - }); -}; - -/** - * Retrieve profit/loss (P&L) sheet. - */ -export function useProfitLossSheet(query, props) { - return useRequestQuery( - [t.FINANCIAL_REPORT, t.PROFIT_LOSS_SHEET, query], - { - method: 'get', - url: '/financial_statements/profit_loss_sheet', - params: query, - headers: { - Accept: 'application/json+table', - }, - }, - { - select: (res) => res.data, - ...props, - }, - ); -} - -export const useProfitLossSheetXlsxExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/profit_loss_sheet', - config: { - headers: { - accept: 'application/xlsx', - }, - params: query, - }, - filename: 'profit_loss_sheet.xlsx', - ...args, - }); -}; - -export const useProfitLossSheetCsvExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/profit_loss_sheet', - config: { - headers: { - accept: 'application/csv', - }, - params: query, - }, - filename: 'profit_loss_sheet.csv', - ...args, - }); -}; - -/** - * Retrieve general ledger (GL) sheet. - */ -export function useGeneralLedgerSheet(query, props) { - return useRequestQuery( - [t.FINANCIAL_REPORT, t.GENERAL_LEDGER, query], - { - method: 'get', - url: '/financial_statements/general_ledger', - params: query, - }, - { - select: (res) => ({ - tableRows: generalLedgerTableRowsReducer(res.data.data), - ...res.data, - }), - defaultData: { - tableRows: [], - data: {}, - query: {}, - }, - ...props, - }, - ); -} - -/** - * Retrieve journal sheet. - */ -export function useJournalSheet(query, props) { - return useRequestQuery( - [t.FINANCIAL_REPORT, t.JOURNAL, query], - { method: 'get', url: '/financial_statements/journal', params: query }, - { - select: (res) => ({ - tableRows: journalTableRowsReducer(res.data.data), - ...res.data, - }), - defaultData: { - data: {}, - tableRows: [], - query: {}, - }, - ...props, - }, - ); -} - -/** - * Retrieve A/R aging summary report. - */ -export function useARAgingSummaryReport(query, props) { - return useRequestQuery( - [t.FINANCIAL_REPORT, t.AR_AGING_SUMMARY, query], - { - method: 'get', - url: '/financial_statements/receivable_aging_summary', - params: query, - headers: { - Accept: 'application/json+table', - }, - }, - { - select: (res) => res.data, - ...props, - }, - ); -} - -export const useARAgingSheetXlsxExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/receivable_aging_summary', - config: { - headers: { - accept: 'application/xlsx', - }, - params: query, - }, - filename: 'receivable_aging_summary.xlsx', - ...args, - }); -}; - -export const useARAgingSheetCsvExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/receivable_aging_summary', - config: { - headers: { - accept: 'application/csv', - }, - params: query, - }, - filename: 'receivable_aging_summary.csv', - ...args, - }); -}; - -/** - * Retrieve A/P aging summary report. - */ -export function useAPAgingSummaryReport(query, props) { - return useRequestQuery( - [t.FINANCIAL_REPORT, t.AP_AGING_SUMMARY, query], - { - method: 'get', - url: '/financial_statements/payable_aging_summary', - params: query, - headers: { - Accept: 'application/json+table', - }, - }, - { - select: (res) => res.data, - ...props, - }, - ); -} - -export const useAPAgingSheetXlsxExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/payable_aging_summary', - config: { - headers: { - accept: 'application/xlsx', - }, - params: query, - }, - filename: 'payable_aging_summary.xlsx', - ...args, - }); -}; - -export const useAPAgingSheetCsvExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/payable_aging_summary', - config: { - headers: { - accept: 'application/csv', - }, - params: query, - }, - filename: 'payable_aging_summary.csv', - ...args, - }); -}; - -/** - * Retrieve inventory valuation. - */ -export function useInventoryValuation(query, props) { - return useRequestQuery( - [t.FINANCIAL_REPORT, t.INVENTORY_VALUATION, query], - { - method: 'get', - url: '/financial_statements/inventory-valuation', - params: query, - }, - { - select: (res) => ({ - tableRows: inventoryValuationReducer(res.data.data), - ...res.data, - }), - defaultData: { - tableRows: [], - data: [], - query: {}, - }, - ...props, - }, - ); -} -/** - * Retrieve purchases by items. - */ -export function usePurchasesByItems(query, props) { - return useRequestQuery( - [t.FINANCIAL_REPORT, t.PURCHASES_BY_ITEMS, query], - { - method: 'get', - url: '/financial_statements/purchases-by-items', - params: query, - }, - { - select: (res) => ({ - tableRows: purchasesByItemsReducer(res.data.data), - ...res.data, - }), - defaultData: { - tableRows: [], - data: [], - query: {}, - }, - ...props, - }, - ); -} - -/** - * Retrieve sales by items. - */ -export function useSalesByItems(query, props) { - return useRequestQuery( - [t.FINANCIAL_REPORT, t.SALES_BY_ITEMS, query], - { - method: 'get', - url: '/financial_statements/sales-by-items', - params: query, - }, - { - select: (res) => ({ - tableRows: salesByItemsReducer(res.data.data), - ...res.data, - }), - defaultData: { - tableRows: [], - data: [], - query: {}, - }, - ...props, - }, - ); -} - -/** - * Retrieve customers balance summary report. - */ -export function useCustomerBalanceSummaryReport(query, props) { - return useRequestQuery( - [t.FINANCIAL_REPORT, t.CUSTOMERS_BALANCE_SUMMARY, query], - { - method: 'get', - url: '/financial_statements/customer-balance-summary', - params: query, - headers: { - Accept: 'application/json+table', - }, - }, - { - select: (res) => ({ - query: res.data.query, - table: res.data.table, - }), - defaultData: { - table: {}, - query: {}, - }, - ...props, - }, - ); -} - -export const useCustomerBalanceSummaryXlsxExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/customer-balance-summary', - config: { - headers: { - accept: 'application/xlsx', - }, - params: query, - }, - filename: 'customer_balance_summary.xlsx', - ...args, - }); -}; - -export const useCustomerBalanceSummaryCsvExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/customer-balance-summary', - config: { - headers: { - accept: 'application/csv', - }, - params: query, - }, - filename: 'customer_balance_summary.csv', - ...args, - }); -}; - -/** - * Retrieve vendors balance summary report. - */ -export function useVendorsBalanceSummaryReport(query, props) { - return useRequestQuery( - [t.FINANCIAL_REPORT, t.VENDORS_BALANCE_SUMMARY, query], - { - method: 'get', - url: '/financial_statements/vendor-balance-summary', - params: query, - headers: { - Accept: 'application/json+table', - }, - }, - - { - select: (res) => ({ - query: res.data.query, - table: res.data.table, - }), - defaultData: { - table: {}, - query: {}, - }, - ...props, - }, - ); -} - -export const useVendorBalanceSummaryXlsxExport = (args) => { - const url = '/financial_statements/vendor-balance-summary'; - const config = { - headers: { - accept: 'application/xlsx', - }, - }; - const filename = 'vendor_balance_summary.xlsx'; - - return useDownloadFile({ - url, - config, - filename, - ...args, - }); -}; - -export const useVendorBalanceSummaryCsvExport = (args) => { - return useDownloadFile({ - url: '/financial_statements/vendor-balance-summary', - config: { - headers: { - accept: 'application/csv', - }, - }, - filename: 'vendor_balance_summary.csv', - ...args, - }); -}; - -/** - * Retrieve customers transactions report. - */ -export function useCustomersTransactionsReport(query, props) { - return useRequestQuery( - [t.FINANCIAL_REPORT, t.CUSTOMERS_TRANSACTIONS, query], - { - method: 'get', - url: '/financial_statements/transactions-by-customers', - params: query, - headers: { - Accept: 'application/json+table', - }, - }, - { - select: (res) => ({ - data: res.data.table, - tableRows: res.data.table.rows, - }), - defaultData: { - tableRows: [], - data: [], - }, - ...props, - }, - ); -} - -export const useCustomersTransactionsXlsxExport = (query, args) => { - const url = '/financial_statements/transactions-by-customers'; - const config = { - headers: { - accept: 'application/xlsx', - }, - params: query, - }; - const filename = 'customers_transactions.xlsx'; - - return useDownloadFile({ - url, - config, - filename, - ...args, - }); -}; - -export const useCustomersTransactionsCsvExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/transactions-by-customers', - config: { - headers: { - accept: 'application/csv', - }, - params: query, - }, - filename: 'customers_transactions.csv', - ...args, - }); -}; - -/** - * Retrieve vendors transactions report. - */ -export function useVendorsTransactionsReport(query, props) { - return useRequestQuery( - [t.FINANCIAL_REPORT, t.VENDORS_TRANSACTIONS, query], - { - method: 'get', - url: '/financial_statements/transactions-by-vendors', - params: query, - headers: { - Accept: 'application/json+table', - }, - }, - { - select: (res) => ({ - data: res.data.table, - tableRows: res.data.table.rows, - }), - defaultData: { - tableRows: [], - data: [], - }, - ...props, - }, - ); -} - -export const useVendorsTransactionsXlsxExport = (query, args) => { - const url = '/financial_statements/transactions-by-vendors'; - const config = { - headers: { - accept: 'application/xlsx', - }, - params: query, - }; - const filename = 'transactions_by_vendor.xlsx'; - - return useDownloadFile({ - url, - config, - filename, - ...args, - }); -}; - -export const useVendorsTransactionsCsvExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/transactions-by-vendors', - config: { - headers: { - accept: 'application/csv', - }, - params: query, - }, - filename: 'transactions_by_vendor.csv', - ...args, - }); -}; - -/** - * Retrieve cash flow statement report. - */ -export function useCashFlowStatementReport(query, props) { - return useRequestQuery( - [t.FINANCIAL_REPORT, t.CASH_FLOW_STATEMENT, query], - { - method: 'get', - url: '/financial_statements/cash-flow', - params: query, - headers: { - Accept: 'application/json+table', - }, - }, - { - select: (res) => ({ - columns: res.data.table.columns, - query: res.data.query, - meta: res.data.meta, - tableRows: res.data.table.rows, - }), - defaultData: { - tableRows: [], - columns: [], - query: {}, - meta: {}, - }, - ...props, - }, - ); -} - -export const useCashFlowStatementXlsxExport = (query, args) => { - const url = '/financial_statements/cash-flow'; - const config = { - headers: { - accept: 'application/xlsx', - }, - params: query, - }; - const filename = 'cashflow_statement.xlsx'; - - return useDownloadFile({ - url, - config, - filename, - ...args, - }); -}; - -export const useCashFlowStatementCsvExport = (query, args) => { - const url = '/financial_statements/cash-flow'; - const config = { - headers: { - accept: 'application/csv', - }, - params: query, - }; - const filename = 'cashflow_statement.csv'; - - return useDownloadFile({ - url, - config, - filename, - ...args, - }); -}; - -/** - * Retrieve inventory item detail report. - */ -export function useInventoryItemDetailsReport(query, props) { - return useRequestQuery( - [t.FINANCIAL_REPORT, t.INVENTORY_ITEM_DETAILS, query], - { - method: 'get', - url: '/financial_statements/inventory-item-details', - params: query, - headers: { - Accept: 'application/json+table', - }, - }, - { - select: (res) => ({ - columns: res.data.table.columns, - query: res.data.query, - meta: res.data.meta, - tableRows: res.data.table.rows, - }), - defaultData: { - tableRows: [], - columns: [], - query: {}, - meta: {}, - }, - ...props, - }, - ); -} - -export const useInventoryItemDetailsXlsxExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/inventory-item-details', - config: { - headers: { - accept: 'application/xlsx', - }, - params: query, - }, - filename: 'inventory_item_details.xlsx', - ...args, - }); -}; - -export const useInventoryItemDetailsCsvExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/inventory-item-details', - config: { - headers: { - accept: 'application/csv', - }, - params: query, - }, - filename: 'inventory_item_details.csv', - ...args, - }); -}; - -/** - * Retrieve transactions by reference report. - */ -export function useTransactionsByReference(query, props) { - return useRequestQuery( - [t.TRANSACTIONS_BY_REFERENCE, query], - { - method: 'get', - url: `/financial_statements/transactions-by-reference`, - params: query, - }, - { - select: (res) => res.data, - defaultData: { - transactions: [], - }, - ...props, - }, - ); -} - -/** - * Retrieves the sales tax liability summary report. - */ -export function useSalesTaxLiabilitySummary(query, props) { - return useRequestQuery( - [t.FINANCIAL_REPORT, t.SALES_TAX_LIABILITY_SUMMARY, query], - { - method: 'get', - url: '/financial_statements/sales-tax-liability-summary', - params: query, - headers: { - Accept: 'application/json+table', - }, - }, - { - select: (res) => res.data, - ...props, - }, - ); -} - -export const useSalesTaxLiabilitySummaryXlsxExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/sales-tax-liability-summary', - config: { - headers: { - accept: 'application/xlsx', - }, - params: query, - }, - filename: 'sales_tax_liability_summary.xlsx', - ...args, - }); -}; - -export const useSalesTaxLiabilitySummaryCsvExport = (query, args) => { - return useDownloadFile({ - url: '/financial_statements/sales-tax-liability-summary', - config: { - headers: { - accept: 'application/csv', - }, - params: query, - }, - filename: 'sales_tax_liability_summary.csv', - ...args, - }); -}; diff --git a/packages/webapp/src/hooks/query/index.tsx b/packages/webapp/src/hooks/query/index.tsx index 0a7f4feff..2be2d8c4a 100644 --- a/packages/webapp/src/hooks/query/index.tsx +++ b/packages/webapp/src/hooks/query/index.tsx @@ -6,7 +6,6 @@ export * from './items'; export * from './itemsCategories'; export * from './inventoryAdjustments'; export * from './expenses'; -export * from './financialReports'; export * from './customers'; export * from './vendors'; export * from './manualJournals'; @@ -37,3 +36,5 @@ export * from './transactionsLocking'; export * from './warehouses'; export * from './branches'; export * from './warehousesTransfers'; +export * from './plaid'; +export * from './FinancialReports'; diff --git a/packages/webapp/src/hooks/query/invoices.tsx b/packages/webapp/src/hooks/query/invoices.tsx index 13009042c..72a8bc7b1 100644 --- a/packages/webapp/src/hooks/query/invoices.tsx +++ b/packages/webapp/src/hooks/query/invoices.tsx @@ -25,6 +25,9 @@ const commonInvalidateQueries = (queryClient) => { // Invalidate financial reports. queryClient.invalidateQueries(t.FINANCIAL_REPORT); + // Invalidate transactions by reference. + queryClient.invalidateQueries(t.TRANSACTIONS_BY_REFERENCE); + // Invalidate accounts. queryClient.invalidateQueries(t.ACCOUNTS); queryClient.invalidateQueries(t.ACCOUNT); @@ -185,7 +188,9 @@ export function useInvoice(invoiceId, props, requestProps) { * Retrieve the invoice pdf document data. */ export function usePdfInvoice(invoiceId) { - return useRequestPdf(`sales/invoices/${invoiceId}`); + return useRequestPdf({ + url: `sales/invoices/${invoiceId}`, + }); } /** @@ -306,3 +311,33 @@ export function useInvoicePaymentTransactions(invoiceId, props) { }, ); } + +export function useSendSaleInvoiceMail(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + ([id, values]) => apiRequest.post(`sales/invoices/${id}/mail`, values), + { + onSuccess: (res, [id, values]) => { + // Common invalidate queries. + commonInvalidateQueries(queryClient); + }, + ...props, + }, + ); +} + +export function useSaleInvoiceDefaultOptions(invoiceId, props) { + return useRequestQuery( + [t.SALE_INVOICE_DEFAULT_OPTIONS, invoiceId], + { + method: 'get', + url: `sales/invoices/${invoiceId}/mail`, + }, + { + select: (res) => res.data.data, + ...props, + }, + ); +} diff --git a/packages/webapp/src/hooks/query/paymentReceives.tsx b/packages/webapp/src/hooks/query/paymentReceives.tsx index 49d53ad67..5fbd03a28 100644 --- a/packages/webapp/src/hooks/query/paymentReceives.tsx +++ b/packages/webapp/src/hooks/query/paymentReceives.tsx @@ -24,6 +24,9 @@ const commonInvalidateQueries = (client) => { // Invalidate financial reports. client.invalidateQueries(t.FINANCIAL_REPORT); + // Invalidate transactions by reference. + client.invalidateQueries(t.TRANSACTIONS_BY_REFERENCE); + // Invalidate customers. client.invalidateQueries(t.CUSTOMERS); client.invalidateQueries(t.CUSTOMER); @@ -230,7 +233,39 @@ export function usePaymentReceiveSMSDetail( /** * Retrieve the payment receive pdf document data. + * @param {number} paymentReceiveId - Payment receive id. */ export function usePdfPaymentReceive(paymentReceiveId) { - return useRequestPdf(`sales/payment_receives/${paymentReceiveId}`); + return useRequestPdf({ url: `sales/payment_receives/${paymentReceiveId}` }); +} + +export function useSendPaymentReceiveMail(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + ([id, values]) => + apiRequest.post(`sales/payment_receives/${id}/mail`, values), + { + onSuccess: (res, [id, values]) => { + // Common invalidate queries. + commonInvalidateQueries(queryClient); + }, + ...props, + }, + ); +} + +export function usePaymentReceiveDefaultOptions(paymentReceiveId, props) { + return useRequestQuery( + [t.PAYMENT_RECEIVE_MAIL_OPTIONS, paymentReceiveId], + { + method: 'get', + url: `sales/payment_receives/${paymentReceiveId}/mail`, + }, + { + select: (res) => res.data.data, + ...props, + }, + ); } diff --git a/packages/webapp/src/hooks/query/plaid.ts b/packages/webapp/src/hooks/query/plaid.ts new file mode 100644 index 000000000..b4fd88e19 --- /dev/null +++ b/packages/webapp/src/hooks/query/plaid.ts @@ -0,0 +1,31 @@ +// @ts-nocheck +import { useMutation } from 'react-query'; +import useApiRequest from '../useRequest'; + +/** + * Retrieves the plaid link token. + */ +export function useGetPlaidLinkToken(props = {}) { + const apiRequest = useApiRequest(); + + return useMutation( + () => apiRequest.post('banking/plaid/link-token', {}, {}), + { + ...props, + }, + ); +} + +/** + * Retrieves the plaid link token. + */ +export function usePlaidExchangeToken(props = {}) { + const apiRequest = useApiRequest(); + + return useMutation( + (data) => apiRequest.post('banking/plaid/exchange-token', data, {}), + { + ...props, + }, + ); +} diff --git a/packages/webapp/src/hooks/query/receipts.tsx b/packages/webapp/src/hooks/query/receipts.tsx index 907cfc8af..2797008d3 100644 --- a/packages/webapp/src/hooks/query/receipts.tsx +++ b/packages/webapp/src/hooks/query/receipts.tsx @@ -21,6 +21,9 @@ const commonInvalidateQueries = (queryClient) => { // Invalidate financial reports. queryClient.invalidateQueries(t.FINANCIAL_REPORT); + // Invalidate the transactions by reference. + queryClient.invalidateQueries(t.TRANSACTIONS_BY_REFERENCE); + // Invalidate the cashflow transactions. queryClient.invalidateQueries(t.CASH_FLOW_TRANSACTIONS); queryClient.invalidateQueries(t.CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY); @@ -159,9 +162,12 @@ export function useReceipt(id, props) { /** * Retrieve the receipt pdf document data. + * @param {number} receiptId - */ -export function usePdfReceipt(ReceiptId) { - return useRequestPdf(`sales/receipts/${ReceiptId}`); +export function usePdfReceipt(receiptId: number) { + return useRequestPdf({ + url: `sales/receipts/${receiptId}`, + }); } export function useRefreshReceipts() { @@ -207,3 +213,36 @@ export function useReceiptSMSDetail(receiptId, props, requestProps) { }, ); } + +/** + * + */ +export function useSendSaleReceiptMail(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + ([id, values]) => apiRequest.post(`sales/receipts/${id}/mail`, values), + { + onSuccess: () => { + // Invalidate queries. + commonInvalidateQueries(queryClient); + }, + ...props, + }, + ); +} + +export function useSaleReceiptDefaultOptions(invoiceId, props) { + return useRequestQuery( + [t.SALE_RECEIPT_MAIL_OPTIONS, invoiceId], + { + method: 'get', + url: `sales/receipts/${invoiceId}/mail`, + }, + { + select: (res) => res.data.data, + ...props, + }, + ); +} diff --git a/packages/webapp/src/hooks/query/transactionsLocking.tsx b/packages/webapp/src/hooks/query/transactionsLocking.tsx index 0be24a26a..e28555fcf 100644 --- a/packages/webapp/src/hooks/query/transactionsLocking.tsx +++ b/packages/webapp/src/hooks/query/transactionsLocking.tsx @@ -1,9 +1,7 @@ // @ts-nocheck import { useQueryClient, useMutation } from 'react-query'; import { useRequestQuery } from '../useQueryRequest'; -import { transformPagination } from '@/utils'; import useApiRequest from '../useRequest'; -import { useRequestPdf } from '../utils'; import t from './types'; // Common invalidate queries. diff --git a/packages/webapp/src/hooks/query/types.tsx b/packages/webapp/src/hooks/query/types.tsx index c0173cab7..5446282e2 100644 --- a/packages/webapp/src/hooks/query/types.tsx +++ b/packages/webapp/src/hooks/query/types.tsx @@ -32,7 +32,7 @@ const FINANCIAL_REPORTS = { REALIZED_GAIN_OR_LOSS: 'REALIZED_GAIN_OR_LOSS', UNREALIZED_GAIN_OR_LOSS: 'UNREALIZED_GAIN_OR_LOSS', PROJECT_PROFITABILITY_SUMMARY: 'PROJECT_PROFITABILITY_SUMMARY', - SALES_TAX_LIABILITY_SUMMARY: 'SALES_TAX_LIABILITY_SUMMARY' + SALES_TAX_LIABILITY_SUMMARY: 'SALES_TAX_LIABILITY_SUMMARY', }; const BILLS = { @@ -69,6 +69,7 @@ const SALE_ESTIMATES = { SALE_ESTIMATE: 'SALE_ESTIMATE', SALE_ESTIMATE_SMS_DETAIL: 'SALE_ESTIMATE_SMS_DETAIL', NOTIFY_SALE_ESTIMATE_BY_SMS: 'NOTIFY_SALE_ESTIMATE_BY_SMS', + SALE_ESTIMATE_MAIL_OPTIONS: 'SALE_ESTIMATE_MAIL_OPTIONS', }; const SALE_RECEIPTS = { @@ -76,6 +77,7 @@ const SALE_RECEIPTS = { SALE_RECEIPT: 'SALE_RECEIPT', SALE_RECEIPT_SMS_DETAIL: 'SALE_RECEIPT_SMS_DETAIL', NOTIFY_SALE_RECEIPT_BY_SMS: 'NOTIFY_SALE_RECEIPT_BY_SMS', + SALE_RECEIPT_MAIL_OPTIONS: 'SALE_RECEIPT_MAIL_OPTIONS', }; const INVENTORY_ADJUSTMENTS = { @@ -101,6 +103,7 @@ const PAYMENT_RECEIVES = { PAYMENT_RECEIVE_EDIT_PAGE: 'PAYMENT_RECEIVE_EDIT_PAGE', PAYMENT_RECEIVE_SMS_DETAIL: 'PAYMENT_RECEIVE_SMS_DETAIL', NOTIFY_PAYMENT_RECEIVE_BY_SMS: 'NOTIFY_PAYMENT_RECEIVE_BY_SMS', + PAYMENT_RECEIVE_MAIL_OPTIONS: 'PAYMENT_RECEIVE_MAIL_OPTIONS', }; const SALE_INVOICES = { @@ -112,6 +115,7 @@ const SALE_INVOICES = { BAD_DEBT: 'BAD_DEBT', CANCEL_BAD_DEBT: 'CANCEL_BAD_DEBT', SALE_INVOICE_PAYMENT_TRANSACTIONS: 'SALE_INVOICE_PAYMENT_TRANSACTIONS', + SALE_INVOICE_DEFAULT_OPTIONS: 'SALE_INVOICE_DEFAULT_OPTIONS', }; const USERS = { @@ -196,6 +200,9 @@ const CASH_FLOW_ACCOUNTS = { CASH_FLOW_TRANSACTION: 'CASH_FLOW_TRANSACTION', CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY: 'CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY', + CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY: + 'CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY', + CASHFLOW_UNCAATEGORIZED_TRANSACTION: 'CASHFLOW_UNCAATEGORIZED_TRANSACTION', }; const TARNSACTIONS_LOCKING = { @@ -222,12 +229,17 @@ const DASHBOARD = { }; const ORGANIZATION = { - ORGANIZATION_MUTATE_BASE_CURRENCY_ABILITIES: 'ORGANIZATION_MUTATE_BASE_CURRENCY_ABILITIES', + ORGANIZATION_MUTATE_BASE_CURRENCY_ABILITIES: + 'ORGANIZATION_MUTATE_BASE_CURRENCY_ABILITIES', }; export const TAX_RATES = { TAX_RATES: 'TAX_RATES', -} +}; + +export const EXCHANGE_RATE = { + EXCHANGE_RATE: 'EXCHANGE_RATE', +}; export default { ...Authentication, @@ -262,5 +274,6 @@ export default { ...BRANCHES, ...DASHBOARD, ...ORGANIZATION, - ...TAX_RATES + ...TAX_RATES, + ...EXCHANGE_RATE, }; diff --git a/packages/webapp/src/hooks/query/vendorCredit.tsx b/packages/webapp/src/hooks/query/vendorCredit.tsx index 48e424099..0d2131543 100644 --- a/packages/webapp/src/hooks/query/vendorCredit.tsx +++ b/packages/webapp/src/hooks/query/vendorCredit.tsx @@ -43,6 +43,9 @@ const commonInvalidateQueries = (queryClient) => { // Invalidate financial reports. queryClient.invalidateQueries(t.FINANCIAL_REPORT); + // Invalidate the transactions by reference. + queryClient.invalidateQueries(t.TRANSACTIONS_BY_REFERENCE); + // Invalidate mutate base currency abilities. queryClient.invalidateQueries(t.ORGANIZATION_MUTATE_BASE_CURRENCY_ABILITIES); }; diff --git a/packages/webapp/src/hooks/state/banking.ts b/packages/webapp/src/hooks/state/banking.ts new file mode 100644 index 000000000..9b6b356ca --- /dev/null +++ b/packages/webapp/src/hooks/state/banking.ts @@ -0,0 +1,32 @@ +import { + getPlaidToken, + setPlaidId, + resetPlaidId, +} from '@/store/banking/banking.reducer'; +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +export const useSetBankingPlaidToken = () => { + const dispatch = useDispatch(); + + return useCallback( + (plaidId: string) => { + dispatch(setPlaidId(plaidId)); + }, + [dispatch], + ); +}; + +export const useGetBankingPlaidToken = () => { + const plaidToken = useSelector(getPlaidToken); + + return plaidToken; +}; + +export const useResetBankingPlaidToken = () => { + const dispatch = useDispatch(); + + return useCallback(() => { + dispatch(resetPlaidId()); + }, [dispatch]); +}; diff --git a/packages/webapp/src/hooks/useRequestPdf.tsx b/packages/webapp/src/hooks/useRequestPdf.tsx index 22df2385e..55d797b2f 100644 --- a/packages/webapp/src/hooks/useRequestPdf.tsx +++ b/packages/webapp/src/hooks/useRequestPdf.tsx @@ -2,7 +2,7 @@ import React from 'react'; import useApiRequest from './useRequest'; -export const useRequestPdf = (url) => { +export const useRequestPdf = (httpProps) => { const apiRequest = useApiRequest(); const [isLoading, setIsLoading] = React.useState(false); const [isLoaded, setIsLoaded] = React.useState(false); @@ -12,9 +12,11 @@ export const useRequestPdf = (url) => { React.useEffect(() => { setIsLoading(true); apiRequest - .get(url, { + .http({ headers: { accept: 'application/pdf' }, responseType: 'blob', + ...httpProps, + url: `/api/${httpProps?.url}`, }) .then((response) => { // Create a Blob from the PDF Stream. diff --git a/packages/webapp/src/lang/ar/index.json b/packages/webapp/src/lang/ar/index.json index 976b049d6..6e3996d91 100644 --- a/packages/webapp/src/lang/ar/index.json +++ b/packages/webapp/src/lang/ar/index.json @@ -20,6 +20,11 @@ "log_in": "تسجيل الدخول", "forget_my_password": "نسيت كلمة المرور الخاصة بي", "keep_me_logged_in": "تذكرني", + "dont_have_an_account": "ليس لديك حساب؟", + "sign_up": "تسجيل", + "return_to": "عودة إلى", + "sign_in": "صفحة الدخول", + "enter_the_email_address_associated_with_your_account": "قم بادخال بريدك الإلكتروني المرتبط بالحساب وسوف نرسل لك رابط لاعادة تعيين كلمة المرور.", "create_an_account": "إنشاء حساب", "need_bigcapital_account": "تحتاج إلى حساب Bigcapital؟", "show": "عرض", diff --git a/packages/webapp/src/lang/en/index.json b/packages/webapp/src/lang/en/index.json index 4a78b4e76..017a6c760 100644 --- a/packages/webapp/src/lang/en/index.json +++ b/packages/webapp/src/lang/en/index.json @@ -19,6 +19,11 @@ "log_in": "Log in", "forget_my_password": "Forget my password", "keep_me_logged_in": "Keep me logged in", + "dont_have_an_account": "Don't have an account?", + "sign_up": "Sign up", + "return_to": "Return to", + "sign_in": "Sign In", + "enter_the_email_address_associated_with_your_account": "Enter the email address associated with your account and we'll send you a link to reset your password.", "create_an_account": "Create an account", "need_bigcapital_account": "Need a Bigcapital account ?", "show": "Show", @@ -653,7 +658,9 @@ "invoice_number_is_not_unqiue": "Invoice number is not unqiue", "sale_receipt_number_not_unique": "Receipt number is not unique", "sale_invoice_number_is_exists": "Sale invoice number is exists", + "sale_invoice.total_smaller_than_paid_amount": "The invoice total is smaller than the invoice paid amount.", "bill_number_exists": "Bill number exists", + "bill.total_smaller_than_paid_amount": "The bill total is smaller than the bill paid amount.", "ok": "Ok!", "quantity_cannot_be_zero_or_empty": "Quantity cannot be zero or empty.", "customer_email": "Customer email", @@ -2070,7 +2077,7 @@ "project_task.dialog.edit_success_message": "The task has been edited successfully.", "project_task.action.edit_task": "Edit Task", "project_task.action.delete_task": "Delete Task", -"project_task.rate": "{rate} / hour", + "project_task.rate": "{rate} / hour", "project_task.fixed_price": "Fixed price", "project_task.non_chargable": "Non-chargeable", "project_task.estimate_hours": "• {estimate_hours}h 0m estimated", @@ -2290,5 +2297,27 @@ "sidebar.new_project": "New Project", "sidebar.new_time_entry": "New Time Entry", "sidebar.project_profitability_summary": "Project Profitability Summary", - "global_error.too_many_requests": "Too many requests" -} + "global_error.too_many_requests": "Too many requests", + + "pref.invoices.termsConditions.field": "Terms & Conditions", + "pref.invoices.customerNotes.field": "Customer Notes", + + "pref.creditNotes.termsConditions.field": "Terms & Conditions", + "pref.creditNotes.customerNotes.field": "Customer Notes", + + "pref.estimates.termsConditions.field": "Terms & Conditions", + "pref.estimates.customerNotes.field": "Customer Notes", + + "pref.receipts.termsConditions.field": "Terms & Conditions", + "pref.receipts.receiptMessage.field": "Receipt Message", + + "preferences.invoices": "Invoices", + "preferences.estimates": "Estimates", + "preferences.creditNotes": "Credit Notes", + "preferences.receipts": "Receipts", + + "preferences.estimates.success_message": "The preferences have been saved successfully.", + "preferences.credit_notes.success_message": "The preferences have been saved successfully.", + "preferences.receipts.success_message": "The preferences have been saved successfully.", + "preferences.invoices.success_message": "The preferences have been saved successfully." +} \ No newline at end of file diff --git a/packages/webapp/src/routes/dashboard.tsx b/packages/webapp/src/routes/dashboard.tsx index 5193c63f8..c1137bc5b 100644 --- a/packages/webapp/src/routes/dashboard.tsx +++ b/packages/webapp/src/routes/dashboard.tsx @@ -473,16 +473,6 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('all_financial_reports'), subscriptionActive: [SUBSCRIPTION_TYPE.MAIN], }, - // Exchange Rates - // { - // path: `/exchange-rates`, - // component: lazy( - // () => import('@/containers/ExchangeRates/ExchangeRatesList'), - // ), - // breadcrumb: intl.get('exchange_rates_list'), - // pageTitle: intl.get('exchange_rates_list'), - // subscriptionActive: [SUBSCRIPTION_TYPE.MAIN], - // }, // Expenses. { path: `/expenses/new`, diff --git a/packages/webapp/src/routes/preferences.tsx b/packages/webapp/src/routes/preferences.tsx index 775efcf82..8031230ed 100644 --- a/packages/webapp/src/routes/preferences.tsx +++ b/packages/webapp/src/routes/preferences.tsx @@ -9,6 +9,10 @@ import SMSIntegration from '../containers/Preferences/SMSIntegration'; import DefaultRoute from '../containers/Preferences/DefaultRoute'; import Warehouses from '../containers/Preferences/Warehouses'; import Branches from '../containers/Preferences/Branches'; +import Invoices from '../containers/Preferences/Invoices/PreferencesInvoices'; +import { PreferencesCreditNotes } from '../containers/Preferences/CreditNotes/PreferencesCreditNotes'; +import { PreferencesEstimates } from '@/containers/Preferences/Estimates/PreferencesEstimates'; +import{ PreferencesReceipts } from '@/containers/Preferences/Receipts/PreferencesReceipts' const BASE_URL = '/preferences'; @@ -23,6 +27,26 @@ export default [ component: Users, exact: true, }, + { + path: `${BASE_URL}/invoices`, + component: Invoices, + exact: true, + }, + { + path: `${BASE_URL}/credit-notes`, + component: PreferencesCreditNotes, + exact: true, + }, + { + path: `${BASE_URL}/estimates`, + component: PreferencesEstimates, + exact: true, + }, + { + path: `${BASE_URL}/receipts`, + component: PreferencesReceipts, + exact: true, + }, { path: `${BASE_URL}/roles`, component: Roles, diff --git a/packages/webapp/src/static/json/icons.tsx b/packages/webapp/src/static/json/icons.tsx index b13e9ffa5..5bfafa141 100644 --- a/packages/webapp/src/static/json/icons.tsx +++ b/packages/webapp/src/static/json/icons.tsx @@ -382,6 +382,12 @@ export default { ], viewBox: '0 0 20 20', }, + tick: { + path: [ + 'M14,3c-0.28,0-0.53,0.11-0.71,0.29L6,10.59L2.71,7.29C2.53,7.11,2.28,7,2,7C1.45,7,1,7.45,1,8c0,0.28,0.11,0.53,0.29,0.71l4,4C5.47,12.89,5.72,13,6,13s0.53-0.11,0.71-0.29l8-8C14.89,4.53,15,4.28,15,4C15,3.45,14.55,3,14,3z', + ], + viewBox: '0 0 16 16', + }, 'swap-vert': { path: [ 'M10.6,10.9V5.4H9v5.5H6.7L9.8,14l3.1-3.1ZM5.1,0,2,3.1H4.3V8.6H5.9V3.1H8.2Z', @@ -561,8 +567,14 @@ export default { }, 'content-copy': { path: [ - 'M15 0H5c-.55 0-1 .45-1 1v2h2V2h8v7h-1v2h2c.55 0 1-.45 1-1V1c0-.55-.45-1-1-1zm-4 4H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zm-1 10H2V6h8v8z' + 'M15 0H5c-.55 0-1 .45-1 1v2h2V2h8v7h-1v2h2c.55 0 1-.45 1-1V1c0-.55-.45-1-1-1zm-4 4H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zm-1 10H2V6h8v8z', ], - viewBox: '0 0 16 16' - } + viewBox: '0 0 16 16', + }, + envelope: { + path: [ + 'M0 4.01v11.91l6.27-6.27L0 4.01zm18.91-1.03H1.09L10 10.97l8.91-7.99zm-5.18 6.66L20 15.92V4.01l-6.27 5.63zm-3.23 2.9c-.13.12-.31.19-.5.19s-.37-.07-.5-.19l-2.11-1.89-6.33 6.33h17.88l-6.33-6.33-2.11 1.89z', + ], + viewBox: '0 0 20 20', + }, }; diff --git a/packages/webapp/src/store/banking/banking.reducer.ts b/packages/webapp/src/store/banking/banking.reducer.ts new file mode 100644 index 000000000..d6a842d32 --- /dev/null +++ b/packages/webapp/src/store/banking/banking.reducer.ts @@ -0,0 +1,23 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; + +interface StorePlaidState { + plaidToken: string; +} + +export const PlaidSlice = createSlice({ + name: 'plaid', + initialState: { + plaidToken: '', + } as StorePlaidState, + reducers: { + setPlaidId: (state: StorePlaidState, action: PayloadAction) => { + state.plaidToken = action.payload; + }, + resetPlaidId: (state: StorePlaidState) => { + state.plaidToken = ''; + } + }, +}); + +export const { setPlaidId, resetPlaidId } = PlaidSlice.actions; +export const getPlaidToken = (state: any) => state.plaid.plaidToken; \ No newline at end of file diff --git a/packages/webapp/src/store/financialStatement/financialStatements.mappers.tsx b/packages/webapp/src/store/financialStatement/financialStatements.mappers.tsx deleted file mode 100644 index ba59df5b6..000000000 --- a/packages/webapp/src/store/financialStatement/financialStatements.mappers.tsx +++ /dev/null @@ -1,191 +0,0 @@ -// @ts-nocheck -import { omit, chain } from 'lodash'; -import moment from 'moment'; - -export const mapBalanceSheetToTableRows = (accounts) => { - return accounts.map((account) => { - return { - ...account, - children: mapBalanceSheetToTableRows([ - ...(account.children ? account.children : []), - ...(account.total && account.children && account.children.length > 0 - ? [ - { - name: `Total ${account.name}`, - row_types: ['total-row', account.section_type], - total: { ...account.total }, - ...(account.total_periods && { - total_periods: account.total_periods, - }), - }, - ] - : []), - ]), - }; - }); -}; - -export const profitLossToTableRowsMapper = () => {}; - -export const journalToTableRowsMapper = (journal) => { - const TYPES = { - ENTRY: 'ENTRY', - TOTAL_ENTRIES: 'TOTAL_ENTRIES', - EMPTY_ROW: 'EMPTY_ROW', - }; - - const entriesMapper = (transaction) => { - return transaction.entries.map((entry, index) => ({ - ...(index === 0 - ? { - date: transaction.date, - reference_type: transaction.reference_type, - reference_id: transaction.reference_id, - reference_type_formatted: transaction.reference_type_formatted, - } - : {}), - rowType: TYPES.ENTRY, - ...entry, - })); - }; - - return chain(journal) - .map((transaction) => { - const entries = entriesMapper(transaction); - - return [ - ...entries, - { - rowType: TYPES.TOTAL_ENTRIES, - currency_code: transaction.currency_code, - credit: transaction.credit, - debit: transaction.debit, - formatted_credit: transaction.formatted_credit, - formatted_debit: transaction.formatted_debit, - }, - { - rowType: TYPES.EMPTY_ROW, - }, - ]; - }) - .flatten() - .value(); -}; - -export const generalLedgerToTableRows = (accounts) => { - return chain(accounts) - .map((account) => { - return { - name: '', - code: account.code, - rowType: 'ACCOUNT_ROW', - date: account.name, - children: [ - { - ...account.opening_balance, - name: 'Opening balance', - rowType: 'OPENING_BALANCE', - }, - ...account.transactions.map((transaction) => ({ - ...transaction, - name: account.name, - code: account.code, - date: moment(transaction.date).format('DD MMM YYYY'), - })), - { - ...account.closing_balance, - name: 'Closing balance', - rowType: 'CLOSING_BALANCE', - }, - ], - }; - }) - .value(); -}; - -export const ARAgingSummaryTableRowsMapper = (sheet, total) => { - const rows = []; - - const mapAging = (agingPeriods) => { - return agingPeriods.reduce((acc, aging, index) => { - acc[`aging-${index}`] = aging.total.formatted_amount; - return acc; - }, {}); - }; - sheet.customers.forEach((customer) => { - const agingRow = mapAging(customer.aging); - - rows.push({ - rowType: 'customer', - name: customer.customer_name, - ...agingRow, - current: customer.current.formatted_amount, - total: customer.total.formatted_amount, - }); - }); - if (rows.length <= 0) { - return []; - } - return [ - ...rows, - { - name: '', - rowType: 'total', - current: sheet.total.current.formatted_amount, - ...mapAging(sheet.total.aging), - total: sheet.total.total.formatted_amount, - }, - ]; -}; - -export const APAgingSummaryTableRowsMapper = (sheet, total) => { - const rows = []; - - const mapAging = (agingPeriods) => { - return agingPeriods.reduce((acc, aging, index) => { - acc[`aging-${index}`] = aging.total.formatted_amount; - return acc; - }, {}); - }; - sheet.vendors.forEach((vendor) => { - const agingRow = mapAging(vendor.aging); - - rows.push({ - rowType: 'vendor', - name: vendor.vendor_name, - ...agingRow, - current: vendor.current.formatted_amount, - total: vendor.total.formatted_amount, - }); - }); - if (rows.length <= 0) { - return []; - } - return [ - ...rows, - { - name: '', - rowType: 'total', - current: sheet.total.current.formatted_amount, - ...mapAging(sheet.total.aging), - total: sheet.total.total.formatted_amount, - }, - ]; -}; - -export const mapTrialBalanceSheetToRows = (sheet) => { - const results = []; - - if (sheet.accounts) { - sheet.accounts.forEach((account) => { - results.push(account); - }); - } - if (sheet.total) { - results.push({ - rowType: 'total', - ...sheet.total, - }); - } - return results; -}; diff --git a/packages/webapp/src/store/reducers.tsx b/packages/webapp/src/store/reducers.tsx index 4a778e1de..ddcc6ff27 100644 --- a/packages/webapp/src/store/reducers.tsx +++ b/packages/webapp/src/store/reducers.tsx @@ -37,6 +37,7 @@ import creditNotes from './CreditNote/creditNote.reducer'; import vendorCredit from './VendorCredit/VendorCredit.reducer'; import warehouseTransfers from './WarehouseTransfer/warehouseTransfer.reducer'; import projects from './Project/projects.reducer'; +import { PlaidSlice } from './banking/banking.reducer'; const appReducer = combineReducers({ authentication, @@ -73,6 +74,7 @@ const appReducer = combineReducers({ vendorCredit, warehouseTransfers, projects, + plaid: PlaidSlice.reducer, }); // Reset the state of a redux store diff --git a/packages/webapp/src/style/pages/Dashboard/Dashboard.scss b/packages/webapp/src/style/pages/Dashboard/Dashboard.scss index b45ead46a..13edd6d11 100644 --- a/packages/webapp/src/style/pages/Dashboard/Dashboard.scss +++ b/packages/webapp/src/style/pages/Dashboard/Dashboard.scss @@ -212,6 +212,16 @@ $dashboard-views-bar-height: 44px; background: rgba(219, 55, 55, 0.1); } } + &.#{$ns}-minimal.#{$ns}-intent-success{ + color: #1c6e42; + + &:hover, + &:focus { + background: rgba(35, 133, 81, 0.1); + color: #1c6e42; + } + } + &.button--blue-highlight { background-color: #ebfaff; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25a16e3de..0d7f044e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -245,6 +245,9 @@ importers: objection-unique: specifier: ^1.2.2 version: 1.2.2(objection@3.0.1) + plaid: + specifier: ^10.3.0 + version: 10.9.0 pluralize: specifier: ^8.0.0 version: 8.0.0 @@ -269,6 +272,9 @@ importers: rtl-detect: specifier: ^1.0.4 version: 1.0.4 + socket.io: + specifier: ^4.7.4 + version: 4.7.4 source-map-loader: specifier: ^4.0.1 version: 4.0.1(webpack@5.76.0) @@ -480,6 +486,27 @@ importers: '@testing-library/user-event': specifier: ^7.2.1 version: 7.2.1(@testing-library/dom@8.20.0) + '@tiptap/core': + specifier: 2.1.13 + version: 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/extension-color': + specifier: latest + version: 2.1.13(@tiptap/core@2.1.13)(@tiptap/extension-text-style@2.1.13) + '@tiptap/extension-list-item': + specifier: 2.1.13 + version: 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-text-style': + specifier: 2.1.13 + version: 2.1.13(@tiptap/core@2.1.13) + '@tiptap/pm': + specifier: 2.1.13 + version: 2.1.13 + '@tiptap/react': + specifier: 2.1.13 + version: 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13)(react-dom@18.2.0)(react@18.2.0) + '@tiptap/starter-kit': + specifier: 2.1.13 + version: 2.1.13(@tiptap/pm@2.1.13) '@types/jest': specifier: ^26.0.15 version: 26.0.24 @@ -513,6 +540,9 @@ importers: '@types/react-transition-group': specifier: ^4.4.5 version: 4.4.5 + '@types/socket.io-client': + specifier: ^3.0.0 + version: 3.0.0 '@types/styled-components': specifier: ^5.1.25 version: 5.1.26 @@ -603,6 +633,12 @@ importers: path-browserify: specifier: ^1.0.1 version: 1.0.1 + plaid: + specifier: ^9.3.0 + version: 9.12.0 + plaid-threads: + specifier: ^11.4.3 + version: 11.5.0(react-dom@18.2.0)(react@18.2.0) prop-types: specifier: 15.8.1 version: 15.8.1 @@ -648,6 +684,9 @@ importers: react-loadable: specifier: ^5.5.0 version: 5.5.0(react@18.2.0) + react-plaid-link: + specifier: ^3.2.1 + version: 3.5.1(react-dom@18.2.0)(react@18.2.0) react-query: specifier: ^3.6.0 version: 3.39.3(react-dom@18.2.0)(react@18.2.0) @@ -723,6 +762,9 @@ importers: semver: specifier: 6.3.0 version: 6.3.0 + socket.io-client: + specifier: ^4.7.4 + version: 4.7.4 style-loader: specifier: 0.23.1 version: 0.23.1 @@ -3917,16 +3959,69 @@ packages: resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} + /@emotion/cache@10.0.29: + resolution: {integrity: sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==} + dependencies: + '@emotion/sheet': 0.9.4 + '@emotion/stylis': 0.8.5 + '@emotion/utils': 0.11.3 + '@emotion/weak-memoize': 0.2.5 + dev: false + + /@emotion/core@10.3.1(react@18.2.0): + resolution: {integrity: sha512-447aUEjPIm0MnE6QYIaFz9VQOHSXf4Iu6EWOIqq11EAPqinkSZmfymPTmlOE3QjLv846lH4JVZBUOtwGbuQoww==} + peerDependencies: + react: '>=16.3.0' + dependencies: + '@babel/runtime': 7.20.13 + '@emotion/cache': 10.0.29 + '@emotion/css': 10.0.27 + '@emotion/serialize': 0.11.16 + '@emotion/sheet': 0.9.4 + '@emotion/utils': 0.11.3 + react: 18.2.0 + dev: false + + /@emotion/css@10.0.27: + resolution: {integrity: sha512-6wZjsvYeBhyZQYNrGoR5yPMYbMBNEnanDrqmsqS1mzDm1cOTu12shvl2j4QHNS36UaTE0USIJawCH9C8oW34Zw==} + dependencies: + '@emotion/serialize': 0.11.16 + '@emotion/utils': 0.11.3 + babel-plugin-emotion: 10.2.2 + dev: false + + /@emotion/hash@0.8.0: + resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} + dev: false + /@emotion/is-prop-valid@1.2.0: resolution: {integrity: sha512-3aDpDprjM0AwaxGE09bOPkNxHpBd+kA6jty3RnaEXdweX1DF1U3VQpPYb0g1IStAuK7SVQ1cy+bNBBKp4W3Fjg==} dependencies: '@emotion/memoize': 0.8.0 dev: false + /@emotion/memoize@0.7.4: + resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==} + dev: false + /@emotion/memoize@0.8.0: resolution: {integrity: sha512-G/YwXTkv7Den9mXDO7AhLWkE3q+I92B+VqAE+dYG4NGPaHZGvt3G8Q0p9vmE+sq7rTGphUbAvmQ9YpbfMQGGlA==} dev: false + /@emotion/serialize@0.11.16: + resolution: {integrity: sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==} + dependencies: + '@emotion/hash': 0.8.0 + '@emotion/memoize': 0.7.4 + '@emotion/unitless': 0.7.5 + '@emotion/utils': 0.11.3 + csstype: 2.6.21 + dev: false + + /@emotion/sheet@0.9.4: + resolution: {integrity: sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA==} + dev: false + /@emotion/stylis@0.8.5: resolution: {integrity: sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==} dev: false @@ -3935,6 +4030,14 @@ packages: resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} dev: false + /@emotion/utils@0.11.3: + resolution: {integrity: sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==} + dev: false + + /@emotion/weak-memoize@0.2.5: + resolution: {integrity: sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==} + dev: false + /@eslint-community/eslint-utils@4.4.0(eslint@8.33.0): resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -5699,6 +5802,34 @@ packages: reselect: 4.1.7 dev: false + /@remirror/core-constants@2.0.2: + resolution: {integrity: sha512-dyHY+sMF0ihPus3O27ODd4+agdHMEmuRdyiZJ2CCWjPV5UFmn17ZbElvk6WOGVE4rdCJKZQCrPV2BcikOMLUGQ==} + dev: false + + /@remirror/core-helpers@3.0.0: + resolution: {integrity: sha512-tusEgQJIqg4qKj6HSBUFcyRnWnziw3neh4T9wOmsPGHFC3w9kl5KSrDb9UAgE8uX6y32FnS7vJ955mWOl3n50A==} + dependencies: + '@remirror/core-constants': 2.0.2 + '@remirror/types': 1.0.1 + '@types/object.omit': 3.0.3 + '@types/object.pick': 1.3.4 + '@types/throttle-debounce': 2.1.0 + case-anything: 2.1.13 + dash-get: 1.0.2 + deepmerge: 4.3.1 + fast-deep-equal: 3.1.3 + make-error: 1.3.6 + object.omit: 3.0.0 + object.pick: 1.3.0 + throttle-debounce: 3.0.1 + dev: false + + /@remirror/types@1.0.1: + resolution: {integrity: sha512-VlZQxwGnt1jtQ18D6JqdIF+uFZo525WEqrfp9BOc3COPpK4+AWCgdnAWL+ho6imWcoINlGjR/+3b6y5C1vBVEA==} + dependencies: + type-fest: 2.19.0 + dev: false + /@rollup/plugin-babel@5.3.1(@babel/core@7.20.12)(rollup@2.79.1): resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} engines: {node: '>= 10.0.0'} @@ -5795,6 +5926,10 @@ packages: resolution: {integrity: sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==} dev: true + /@socket.io/component-emitter@3.1.0: + resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==} + dev: false + /@surma/rollup-plugin-off-main-thread@2.2.3: resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} dependencies: @@ -5977,6 +6112,273 @@ packages: '@testing-library/dom': 8.20.0 dev: false + /@tiptap/core@2.1.13(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-cMC8bgTN63dj1Mv82iDeeLl6sa9kY0Pug8LSalxVEptRmyFVsVxGgu2/6Y3T+9aCYScxfS06EkA8SdzFMAwYTQ==} + peerDependencies: + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-blockquote@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-oe6wSQACmODugoP9XH3Ouffjy4BsOBWfTC+dETHNCG6ZED6ShHN3CB9Vr7EwwRgmm2WLaKAjMO1sVumwH+Z1rg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-bold@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-6cHsQTh/rUiG4jkbJer3vk7g60I5tBwEBSGpdxmEHh83RsvevD8+n92PjA24hYYte5RNlATB011E1wu8PVhSvw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-bubble-menu@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-Hm7e1GX3AI6lfaUmr6WqsS9MMyXIzCkhh+VQi6K8jj4Q4s8kY4KPoAyD/c3v9pZ/dieUtm2TfqrOCkbHzsJQBg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + tippy.js: 6.3.7 + dev: false + + /@tiptap/extension-bullet-list@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-NkWlQ5bLPUlcROj6G/d4oqAxMf3j3wfndGOPp0z8OoXJtVbVoXl/aMSlLbVgE6n8r6CS8MYxKhXNxrb7Ll2foA==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-code-block@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-E3tweNExPOV+t1ODKX0MDVsS0aeHGWc1ECt+uyp6XwzsN0bdF2A5+pttQqM7sTcMnQkVACGFbn9wDeLRRcfyQg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-code@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-f5fLYlSgliVVa44vd7lQGvo49+peC+Z2H0Fn84TKNCH7tkNZzouoJsHYn0/enLaQ9Sq+24YPfqulfiwlxyiT8w==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-color@2.1.13(@tiptap/core@2.1.13)(@tiptap/extension-text-style@2.1.13): + resolution: {integrity: sha512-T3tJXCIfFxzIlGOhvbPVIZa3y36YZRPYIo2TKsgkTz8LiMob6hRXXNFjsrFDp2Fnu3DrBzyvrorsW7767s4eYg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/extension-text-style': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/extension-text-style': 2.1.13(@tiptap/core@2.1.13) + dev: false + + /@tiptap/extension-document@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-wLwiTWsVmZTGIE5duTcHRmW4ulVxNW4nmgfpk95+mPn1iKyNGtrVhGWleLhBlTj+DWXDtcfNWZgqZkZNzhkqYQ==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-dropcursor@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-NAyJi4BJxH7vl/2LNS1X0ndwFKjEtX+cRgshXCnMyh7qNpIRW6Plczapc/W1OiMncOEhZJfpZfkRSfwG01FWFg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-floating-menu@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-9Oz7pk1Nts2+EyY+rYfnREGbLzQ5UFazAvRhF6zAJdvyuDmAYm0Jp6s0GoTrpV0/dJEISoFaNpPdMJOb9EBNRw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + tippy.js: 6.3.7 + dev: false + + /@tiptap/extension-gapcursor@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-Cl5apsoTcyPPCgE3ThufxQxZ1wyqqh+9uxUN9VF9AbeTkid6oPZvKXwaILf6AFnkSy+SuKrb9kZD2iaezxpzXw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-hard-break@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-TGkMzMQayuKg+vN4du0x1ahEItBLcCT1jdWeRsjdM8gHfzbPLdo4PQhVsvm1I0xaZmbJZelhnVsUwRZcIu1WNA==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-heading@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-PEmc19QLmlVUTiHWoF0hpgNTNPNU0nlaFmMKskzO+cx5Df4xvHmv/UqoIwp7/UFbPMkfVJT1ozQU7oD1IWn9Hg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-history@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-1ouitThGTBUObqw250aDwGLMNESBH5PRXIGybsCFO1bktdmWtEw7m72WY41EuX2BH8iKJpcYPerl3HfY1vmCNw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-horizontal-rule@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-7OgjgNqZXvBejgULNdMSma2M1nzv4bbZG+FT5XMFZmEOxR9IB1x/RzChjPdeicff2ZK2sfhMBc4Y9femF5XkUg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-italic@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-HyDJfuDn5hzwGKZiANcvgz6wcum6bEgb4wmJnfej8XanTMJatNVv63TVxCJ10dSc9KGpPVcIkg6W8/joNXIEbw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-list-item@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-6e8iiCWXOiJTl1XOwVW2tc0YG18h70HUtEHFCx2m5HspOGFKsFEaSS3qYxOheM9HxlmQeDt8mTtqftRjEFRxPQ==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-ordered-list@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-UO4ZAL5Vrr1WwER5VjgmeNIWHpqy9cnIRo1En07gZ0OWTjs1eITPcu+4TCn1ZG6DhoFvAQzE5DTxxdhIotg+qw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-paragraph@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-cEoZBJrsQn69FPpUMePXG/ltGXtqKISgypj70PEHXt5meKDjpmMVSY4/8cXvFYEYsI9GvIwyAK0OrfAHiSoROA==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-strike@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-VN6zlaCNCbyJUCDyBFxavw19XmQ4LkCh8n20M8huNqW77lDGXA2A7UcWLHaNBpqAijBRu9mWI8l4Bftyf2fcAw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-text-style@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-K9/pNHxpZKQoc++crxrsppVUSeHv8YevfY2FkJ4YMaekGcX+q4BRrHR0tOfii4izAUPJF2L0/PexLQaWXtAY1w==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-text@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-zzsTTvu5U67a8WjImi6DrmpX2Q/onLSaj+LRWPh36A1Pz2WaxW5asZgaS+xWCnR+UrozlCALWa01r7uv69jq0w==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/pm@2.1.13: + resolution: {integrity: sha512-zNbA7muWsHuVg12GrTgN/j119rLePPq5M8dZgkKxUwdw8VmU3eUyBp1SihPEXJ2U0MGdZhNhFX7Y74g11u66sg==} + dependencies: + prosemirror-changeset: 2.2.1 + prosemirror-collab: 1.3.1 + prosemirror-commands: 1.5.2 + prosemirror-dropcursor: 1.8.1 + prosemirror-gapcursor: 1.3.2 + prosemirror-history: 1.3.2 + prosemirror-inputrules: 1.3.0 + prosemirror-keymap: 1.2.2 + prosemirror-markdown: 1.12.0 + prosemirror-menu: 1.2.4 + prosemirror-model: 1.19.4 + prosemirror-schema-basic: 1.2.2 + prosemirror-schema-list: 1.3.0 + prosemirror-state: 1.4.3 + prosemirror-tables: 1.3.5 + prosemirror-trailing-node: 2.0.7(prosemirror-model@1.19.4)(prosemirror-state@1.4.3)(prosemirror-view@1.32.7) + prosemirror-transform: 1.8.0 + prosemirror-view: 1.32.7 + dev: false + + /@tiptap/react@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Dq3f8EtJnpImP3iDtJo+7bulnN9SJZRZcVVzxHXccLcC2MxtmDdlPGZjP+wxO800nd8toSIOd5734fPNf/YcfA==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/extension-bubble-menu': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-floating-menu': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@tiptap/starter-kit@2.1.13(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-ph/mUR/OwPtPkZ5rNHINxubpABn8fHnvJSdhXFrY/q6SKoaO11NZXgegRaiG4aL7O6Sz4LsZVw6Sm0Ae+GJmrg==} + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/extension-blockquote': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-bold': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-bullet-list': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-code': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-code-block': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-document': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-dropcursor': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-gapcursor': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-hard-break': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-heading': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-history': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-horizontal-rule': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-italic': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-list-item': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-ordered-list': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-paragraph': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-strike': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-text': 2.1.13(@tiptap/core@2.1.13) + transitivePeerDependencies: + - '@tiptap/pm' + dev: false + /@tootallnate/once@1.1.2: resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} @@ -6080,10 +6482,20 @@ packages: '@types/node': 14.18.36 dev: false + /@types/cookie@0.4.1: + resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} + dev: false + /@types/cookiejar@2.1.2: resolution: {integrity: sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==} dev: true + /@types/cors@2.8.17: + resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} + dependencies: + '@types/node': 14.18.36 + dev: false + /@types/dom4@2.0.2: resolution: {integrity: sha512-Rt4IC1T7xkCWa0OG1oSsPa0iqnxlDeQqKXZAHrQGLb7wFGncWm85MaxKUjAGejOrUynOgWlFi4c6S6IyJwoK4g==} dev: false @@ -6221,7 +6633,7 @@ packages: /@types/keyv@3.1.4: resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} dependencies: - '@types/node': 18.13.0 + '@types/node': 14.18.36 dev: false /@types/knex@0.16.1(mysql2@1.7.0)(mysql@2.18.1): @@ -6285,6 +6697,14 @@ packages: /@types/normalize-package-data@2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} + /@types/object.omit@3.0.3: + resolution: {integrity: sha512-xrq4bQTBGYY2cw+gV4PzoG2Lv3L0pjZ1uXStRRDQoATOYW1lCsFQHhQ+OkPhIcQoqLjAq7gYif7D14Qaa6Zbew==} + dev: false + + /@types/object.pick@1.3.4: + resolution: {integrity: sha512-5PjwB0uP2XDp3nt5u5NJAG2DORHIRClPzWT/TTZhJ2Ekwe8M5bA9tvPdi9NO/n2uvu2/ictat8kgqvLfcIE1SA==} + dev: false + /@types/parse-json@4.0.0: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} @@ -6393,7 +6813,7 @@ packages: /@types/responselike@1.0.1: resolution: {integrity: sha512-TiGnitEDxj2X0j+98Eqk5lv/Cij8oHd32bU4D/Yw6AOq7vvTk0gSD2GPj0G/HkvhMoVsdlhYF4yqqlyPBTM6Sg==} dependencies: - '@types/node': 18.13.0 + '@types/node': 14.18.36 dev: false /@types/retry@0.12.0: @@ -6428,6 +6848,17 @@ packages: '@types/node': 14.18.36 dev: false + /@types/socket.io-client@3.0.0: + resolution: {integrity: sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg==} + deprecated: This is a stub types definition. socket.io-client provides its own type definitions, so you do not need this installed. + dependencies: + socket.io-client: 4.7.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: false + /@types/sockjs@0.3.34: resolution: {integrity: sha512-R+n7qBFnm/6jinlteC9DBL5dGiDGjWAvjo4viUanpnc/dG1y7uDoacXPIQ/PQEg1fI912SMHIa014ZjRpvDw4g==} dependencies: @@ -6482,6 +6913,10 @@ packages: pretty-format: 25.5.0 dev: false + /@types/throttle-debounce@2.1.0: + resolution: {integrity: sha512-5eQEtSCoESnh2FsiLTxE121IiE60hnMqcb435fShf4bpLRjEu1Eoekht23y6zXS9Ts3l+Szu3TARnTsA0GkOkQ==} + dev: false + /@types/triple-beam@1.3.2: resolution: {integrity: sha512-txGIh+0eDFzKGC25zORnswy+br1Ha7hj5cMVwKIU7+s0U2AxxJru/jZSMU6OC9MJWP6+pc/hc6ZjyZShpsyY2g==} dev: false @@ -7214,6 +7649,12 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + /add-dom-event-listener@1.1.0: + resolution: {integrity: sha512-WCxx1ixHT0GQU9hb0KI/mhgRQhnU+U3GvwY6ZvVjYq8rsihIGoaIOUbY0yMPBxLH5MDtr0kz3fisWGNcbWW7Jw==} + dependencies: + object-assign: 4.1.1 + dev: false + /add-stream@1.0.0: resolution: {integrity: sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==} dev: true @@ -7850,6 +8291,14 @@ packages: engines: {node: '>=4'} dev: false + /axios@0.21.4: + resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} + dependencies: + follow-redirects: 1.15.2 + transitivePeerDependencies: + - debug + dev: false + /axios@1.6.0: resolution: {integrity: sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==} dependencies: @@ -7930,6 +8379,21 @@ packages: webpack: 5.76.0(webpack-cli@4.10.0) dev: false + /babel-plugin-emotion@10.2.2: + resolution: {integrity: sha512-SMSkGoqTbTyUTDeuVuPIWifPdUGkTk1Kf9BWRiXIOIcuyMfsdp2EjeiiFvOzX8NOBvEh/ypKYvUh2rkgAJMCLA==} + dependencies: + '@babel/helper-module-imports': 7.22.15 + '@emotion/hash': 0.8.0 + '@emotion/memoize': 0.7.4 + '@emotion/serialize': 0.11.16 + babel-plugin-macros: 2.8.0 + babel-plugin-syntax-jsx: 6.18.0 + convert-source-map: 1.9.0 + escape-string-regexp: 1.0.5 + find-root: 1.1.0 + source-map: 0.5.7 + dev: false + /babel-plugin-istanbul@5.2.0: resolution: {integrity: sha512-5LphC0USA8t4i1zCtjbbNb6jJj/9+X6P37Qfirc/70EQ34xKlMW+a1RHGwxGI+SwWpNwZ27HqvzAobeqaXwiZw==} engines: {node: '>=6'} @@ -7972,6 +8436,14 @@ packages: '@types/babel__traverse': 7.18.3 dev: false + /babel-plugin-macros@2.8.0: + resolution: {integrity: sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==} + dependencies: + '@babel/runtime': 7.20.13 + cosmiconfig: 6.0.0 + resolve: 1.22.6 + dev: false + /babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} @@ -8148,6 +8620,13 @@ packages: - supports-color dev: false + /babel-runtime@6.26.0: + resolution: {integrity: sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==} + dependencies: + core-js: 2.6.12 + regenerator-runtime: 0.11.1 + dev: false + /babel-walk@3.0.0-canary-5: resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==} engines: {node: '>= 10.0.0'} @@ -8176,6 +8655,11 @@ packages: /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + /base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + dev: false + /base@0.11.2: resolution: {integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==} engines: {node: '>=0.10.0'} @@ -8856,6 +9340,11 @@ packages: engines: {node: '>=0.10.0'} dev: false + /case-anything@2.1.13: + resolution: {integrity: sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==} + engines: {node: '>=12.13'} + dev: false + /case-sensitive-paths-webpack-plugin@2.4.0: resolution: {integrity: sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==} engines: {node: '>=4'} @@ -9380,9 +9869,19 @@ packages: resolution: {integrity: sha512-8njCHOTtFFLtegk6zQo0kkVX1rngygb/KQI6z1qZxlFI3scluC+LVTCFbrkWjBv4vvLlbQ9t88IPMC6k95VTTg==} dev: false + /component-classes@1.2.6: + resolution: {integrity: sha512-hPFGULxdwugu1QWW3SvVOCUHLzO34+a2J6Wqy0c5ASQkfi9/8nZcBB0ZohaEbXOQlCflMAEMmEWk7u7BVs4koA==} + dependencies: + component-indexof: 0.0.3 + dev: false + /component-emitter@1.3.0: resolution: {integrity: sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==} + /component-indexof@0.0.3: + resolution: {integrity: sha512-puDQKvx/64HZXb4hBwIcvQLaLgux8o1CbWl39s41hrIIZDl1lJiD5jc22gj3RBeGK0ovxALDYpIbyjqDUUl0rw==} + dev: false + /compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -9610,6 +10109,11 @@ packages: engines: {node: '>= 0.6'} dev: false + /cookie@0.4.2: + resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} + engines: {node: '>= 0.6'} + dev: false + /cookie@0.5.0: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} @@ -9654,6 +10158,12 @@ packages: requiresBuild: true dev: false + /core-js@2.6.12: + resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==} + deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + requiresBuild: true + dev: false + /core-js@3.27.2: resolution: {integrity: sha512-9ashVQskuh5AZEZ1JdQWp1GqSoC1e1G87MzRqg2gIfVAQ7Qn9K+uFj8EcniUFA4P2NLZfV+TOlX1SzoKfo+s7w==} requiresBuild: true @@ -9666,6 +10176,14 @@ packages: /core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + /cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + dev: false + /cosmiconfig-typescript-loader@4.3.0(@types/node@18.13.0)(cosmiconfig@8.0.0)(ts-node@10.9.1)(typescript@4.9.5): resolution: {integrity: sha512-NTxV1MFfZDLPiBMjxbHRwSh5LaLcPMwNdCutmnHJCKoVnlvldPWlllonKwrsRJ5pYZBIBGRWWU2tfvzxgeSW5Q==} engines: {node: '>=12', npm: '>=6'} @@ -9809,6 +10327,10 @@ packages: /create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + /crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + dev: false + /cron-parser@3.5.0: resolution: {integrity: sha512-wyVZtbRs6qDfFd8ap457w3XVntdvqcwBGxBoTvJQH9KGVKL/fB+h2k3C8AqiVxvUQKN1Ps/Ns46CNViOpVDhfQ==} engines: {node: '>=0.8'} @@ -9908,6 +10430,13 @@ packages: uid-safe: 2.1.5 dev: false + /css-animation@1.6.1: + resolution: {integrity: sha512-/48+/BaEaHRY6kNQ2OIPzKf9A6g8WjZYjhiNDNuIVbsm5tXCGIAsHDjB4Xu1C4vXJtUWZo26O68OQkDpNBaPog==} + dependencies: + babel-runtime: 6.26.0 + component-classes: 1.2.6 + dev: false + /css-blank-pseudo@3.0.3(postcss@8.4.21): resolution: {integrity: sha512-VS90XWtsHGqoM0t4KpH053c4ehxZ2E6HtGI7x68YFV0pTo/QmkV/YFA+NnlvK8guxZVNWGQhVNJGC39Q8XF4OQ==} engines: {node: ^12 || ^14 || >=16} @@ -10172,6 +10701,10 @@ packages: cssom: 0.3.8 dev: false + /csstype@2.6.21: + resolution: {integrity: sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==} + dev: false + /csstype@3.1.1: resolution: {integrity: sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==} dev: false @@ -10203,6 +10736,10 @@ packages: engines: {node: '>=8'} dev: true + /dash-get@1.0.2: + resolution: {integrity: sha512-4FbVrHDwfOASx7uQVxeiCTo7ggSdYZbqs8lH+WU6ViypPlDbe9y6IP5VVUDQBv9DcnyaiPT5XT0UWHgJ64zLeQ==} + dev: false + /dashdash@1.14.1: resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} engines: {node: '>=0.10'} @@ -10687,6 +11224,10 @@ packages: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dev: false + /dom-align@1.12.4: + resolution: {integrity: sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==} + dev: false + /dom-converter@0.2.0: resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==} dependencies: @@ -10947,6 +11488,45 @@ packages: dependencies: once: 1.4.0 + /engine.io-client@6.5.3: + resolution: {integrity: sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==} + dependencies: + '@socket.io/component-emitter': 3.1.0 + debug: 4.3.4(supports-color@5.5.0) + engine.io-parser: 5.2.2 + ws: 8.11.0 + xmlhttprequest-ssl: 2.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: false + + /engine.io-parser@5.2.2: + resolution: {integrity: sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==} + engines: {node: '>=10.0.0'} + dev: false + + /engine.io@6.5.4: + resolution: {integrity: sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==} + engines: {node: '>=10.2.0'} + dependencies: + '@types/cookie': 0.4.1 + '@types/cors': 2.8.17 + '@types/node': 14.18.36 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.4.2 + cors: 2.8.5 + debug: 4.3.4(supports-color@5.5.0) + engine.io-parser: 5.2.2 + ws: 8.11.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: false + /enhanced-resolve@0.9.1: resolution: {integrity: sha512-kxpoMgrdtkXZ5h0SeraBS1iRntpTpQ3R8ussdb38+UAFnMGX5DDyJXePm+OCHOcoXvHDw7mc2erbJBpDnl7TPw==} engines: {node: '>=0.6'} @@ -10974,6 +11554,11 @@ packages: resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} dev: false + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: false + /env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -11850,6 +12435,10 @@ packages: signal-exit: 3.0.7 strip-final-newline: 2.0.0 + /exenv@1.2.2: + resolution: {integrity: sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==} + dev: false + /exit@0.1.2: resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} engines: {node: '>= 0.8.0'} @@ -12286,7 +12875,6 @@ packages: /find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} - dev: true /find-up@1.1.2: resolution: {integrity: sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA==} @@ -16343,6 +16931,12 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} dev: true + /linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + dependencies: + uc.micro: 2.0.0 + dev: false + /load-json-file@1.1.0: resolution: {integrity: sha512-cy7ZdNRXdablkXYNI049pthVeXFurRyb9+hA/dZzerZ0pGTx42z+y+ssxBaVV2l70t1muq5IdKhn4UtcoGUY9A==} engines: {node: '>=0.10.0'} @@ -16751,6 +17345,18 @@ packages: object-visit: 1.0.1 dev: false + /markdown-it@14.0.0: + resolution: {integrity: sha512-seFjF0FIcPt4P9U39Bq1JYblX0KZCjDLFFQPHpL5AzHpqPEKtosxmdq/LTVZnjfH7tjt9BxStm+wXcDBNuYmzw==} + hasBin: true + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.0.0 + dev: false + /match-sorter@4.2.1: resolution: {integrity: sha512-s+3h9TiZU9U1pWhIERHf8/f4LmBN6IXaRgo2CI17+XGByGS1GvG5VvXK9pcGyCjGe3WM3mSYRC3ipGrd5UEVgw==} dependencies: @@ -16814,6 +17420,10 @@ packages: resolution: {integrity: sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==} dev: false + /mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + dev: false + /media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -16826,6 +17436,10 @@ packages: fs-monkey: 1.0.5 dev: false + /memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + dev: false + /memory-cache@0.2.0: resolution: {integrity: sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==} dev: false @@ -18111,6 +18725,13 @@ packages: make-iterator: 1.0.1 dev: false + /object.omit@3.0.0: + resolution: {integrity: sha512-EO+BCv6LJfu+gBIF3ggLicFebFLN5zqzz/WWJlMFfkMyGth+oBkhxzDl0wx2W4GkLzuQs/FsSkXZb2IMWQqmBQ==} + engines: {node: '>=0.10.0'} + dependencies: + is-extendable: 1.0.1 + dev: false + /object.pick@1.3.0: resolution: {integrity: sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==} engines: {node: '>=0.10.0'} @@ -18287,6 +18908,10 @@ packages: readable-stream: 2.3.7 dev: false + /orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + dev: false + /os-browserify@0.3.0: resolution: {integrity: sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==} dev: true @@ -18828,6 +19453,42 @@ packages: find-up: 3.0.0 dev: false + /plaid-threads@11.5.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-KS3w5Ydv+aC7wS1XxiWeDUhQHHFQ/5dQOoAQwdg+3DxFHbh5nSPH1L4Zy9qHru5FVDmC5DQ6/9XhJwzZ9BhpTA==} + peerDependencies: + react: '*' + react-dom: '*' + dependencies: + '@popperjs/core': 2.11.8 + classnames: 2.3.2 + prism-react-renderer: 1.3.5(react@18.2.0) + rc-calendar: 9.15.11(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-modal: 3.16.1(react-dom@18.2.0)(react@18.2.0) + react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0)(react@18.2.0) + react-select: 3.2.0(react-dom@18.2.0)(react@18.2.0) + react-textarea-autosize: 7.1.2(react@18.2.0) + dev: false + + /plaid@10.9.0: + resolution: {integrity: sha512-Dhe4+krfCpDem3jhEqWQLkqiKcOOOkaox2nW1nZtZimsyfyrJ0QPQQWAUdo8IJet4izpR/ecqW+eGLlLdfdl+Q==} + engines: {node: '>=10.0.0'} + dependencies: + axios: 0.21.4 + transitivePeerDependencies: + - debug + dev: false + + /plaid@9.12.0: + resolution: {integrity: sha512-Gc6rhfEUakAjPWUOdmcZ2axZyurHgKLxx892FLkQEQhOqh5k8uZpbNxPBGOk+Uhh75wWfwWLjokPUnJb93bjwQ==} + engines: {node: '>=10.0.0'} + dependencies: + axios: 0.21.4 + transitivePeerDependencies: + - debug + dev: false + /playwright-core@1.36.1: resolution: {integrity: sha512-7+tmPuMcEW4xeCL9cp9KxmYpQYHKkyjwoXRnoeTowaeNat8PoBMk/HwCYhqkH2fRkshfKEOiVus/IhID2Pg8kg==} engines: {node: '>=16'} @@ -19754,6 +20415,14 @@ packages: engines: {node: '>= 0.8'} dev: false + /prism-react-renderer@1.3.5(react@18.2.0): + resolution: {integrity: sha512-IJ+MSwBWKG+SM3b2SUfdrhC+gu01QkV2KmRQgREThBfSQRoufqRfxfHUxpG1WcaFjP+kojcFyO9Qqtpgt3qLCg==} + peerDependencies: + react: '>=0.14.9' + dependencies: + react: 18.2.0 + dev: false + /proc-log@2.0.1: resolution: {integrity: sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -19867,6 +20536,149 @@ packages: resolution: {integrity: sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==} dev: false + /prosemirror-changeset@2.2.1: + resolution: {integrity: sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ==} + dependencies: + prosemirror-transform: 1.8.0 + dev: false + + /prosemirror-collab@1.3.1: + resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==} + dependencies: + prosemirror-state: 1.4.3 + dev: false + + /prosemirror-commands@1.5.2: + resolution: {integrity: sha512-hgLcPaakxH8tu6YvVAaILV2tXYsW3rAdDR8WNkeKGcgeMVQg3/TMhPdVoh7iAmfgVjZGtcOSjKiQaoeKjzd2mQ==} + dependencies: + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + dev: false + + /prosemirror-dropcursor@1.8.1: + resolution: {integrity: sha512-M30WJdJZLyXHi3N8vxN6Zh5O8ZBbQCz0gURTfPmTIBNQ5pxrdU7A58QkNqfa98YEjSAL1HUyyU34f6Pm5xBSGw==} + dependencies: + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + prosemirror-view: 1.32.7 + dev: false + + /prosemirror-gapcursor@1.3.2: + resolution: {integrity: sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==} + dependencies: + prosemirror-keymap: 1.2.2 + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-view: 1.32.7 + dev: false + + /prosemirror-history@1.3.2: + resolution: {integrity: sha512-/zm0XoU/N/+u7i5zepjmZAEnpvjDtzoPWW6VmKptcAnPadN/SStsBjMImdCEbb3seiNTpveziPTIrXQbHLtU1g==} + dependencies: + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + prosemirror-view: 1.32.7 + rope-sequence: 1.3.4 + dev: false + + /prosemirror-inputrules@1.3.0: + resolution: {integrity: sha512-z1GRP2vhh5CihYMQYsJSa1cOwXb3SYxALXOIfAkX8nZserARtl9LiL+CEl+T+OFIsXc3mJIHKhbsmRzC0HDAXA==} + dependencies: + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + dev: false + + /prosemirror-keymap@1.2.2: + resolution: {integrity: sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==} + dependencies: + prosemirror-state: 1.4.3 + w3c-keyname: 2.2.8 + dev: false + + /prosemirror-markdown@1.12.0: + resolution: {integrity: sha512-6F5HS8Z0HDYiS2VQDZzfZP6A0s/I0gbkJy8NCzzDMtcsz3qrfqyroMMeoSjAmOhDITyon11NbXSzztfKi+frSQ==} + dependencies: + markdown-it: 14.0.0 + prosemirror-model: 1.19.4 + dev: false + + /prosemirror-menu@1.2.4: + resolution: {integrity: sha512-S/bXlc0ODQup6aiBbWVsX/eM+xJgCTAfMq/nLqaO5ID/am4wS0tTCIkzwytmao7ypEtjj39i7YbJjAgO20mIqA==} + dependencies: + crelt: 1.0.6 + prosemirror-commands: 1.5.2 + prosemirror-history: 1.3.2 + prosemirror-state: 1.4.3 + dev: false + + /prosemirror-model@1.19.4: + resolution: {integrity: sha512-RPmVXxUfOhyFdayHawjuZCxiROsm9L4FCUA6pWI+l7n2yCBsWy9VpdE1hpDHUS8Vad661YLY9AzqfjLhAKQ4iQ==} + dependencies: + orderedmap: 2.1.1 + dev: false + + /prosemirror-schema-basic@1.2.2: + resolution: {integrity: sha512-/dT4JFEGyO7QnNTe9UaKUhjDXbTNkiWTq/N4VpKaF79bBjSExVV2NXmJpcM7z/gD7mbqNjxbmWW5nf1iNSSGnw==} + dependencies: + prosemirror-model: 1.19.4 + dev: false + + /prosemirror-schema-list@1.3.0: + resolution: {integrity: sha512-Hz/7gM4skaaYfRPNgr421CU4GSwotmEwBVvJh5ltGiffUJwm7C8GfN/Bc6DR1EKEp5pDKhODmdXXyi9uIsZl5A==} + dependencies: + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + dev: false + + /prosemirror-state@1.4.3: + resolution: {integrity: sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==} + dependencies: + prosemirror-model: 1.19.4 + prosemirror-transform: 1.8.0 + prosemirror-view: 1.32.7 + dev: false + + /prosemirror-tables@1.3.5: + resolution: {integrity: sha512-JSZ2cCNlApu/ObAhdPyotrjBe2cimniniTpz60YXzbL0kZ+47nEYk2LWbfKU2lKpBkUNquta2PjteoNi4YCluQ==} + dependencies: + prosemirror-keymap: 1.2.2 + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + prosemirror-view: 1.32.7 + dev: false + + /prosemirror-trailing-node@2.0.7(prosemirror-model@1.19.4)(prosemirror-state@1.4.3)(prosemirror-view@1.32.7): + resolution: {integrity: sha512-8zcZORYj/8WEwsGo6yVCRXFMOfBo0Ub3hCUvmoWIZYfMP26WqENU0mpEP27w7mt8buZWuGrydBewr0tOArPb1Q==} + peerDependencies: + prosemirror-model: ^1.19.0 + prosemirror-state: ^1.4.2 + prosemirror-view: ^1.31.2 + dependencies: + '@remirror/core-constants': 2.0.2 + '@remirror/core-helpers': 3.0.0 + escape-string-regexp: 4.0.0 + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-view: 1.32.7 + dev: false + + /prosemirror-transform@1.8.0: + resolution: {integrity: sha512-BaSBsIMv52F1BVVMvOmp1yzD3u65uC3HTzCBQV1WDPqJRQ2LuHKcyfn0jwqodo8sR9vVzMzZyI+Dal5W9E6a9A==} + dependencies: + prosemirror-model: 1.19.4 + dev: false + + /prosemirror-view@1.32.7: + resolution: {integrity: sha512-pvxiOoD4shW41X5bYDjRQk3DSG4fMqxh36yPMt7VYgU3dWRmqFzWJM/R6zeo1KtC8nyk717ZbQND3CC9VNeptw==} + dependencies: + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + dev: false + /proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} dev: true @@ -20021,6 +20833,11 @@ packages: pump: 2.0.1 dev: false + /punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + dev: false + /punycode@1.3.2: resolution: {integrity: sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==} dev: true @@ -20172,6 +20989,72 @@ packages: unpipe: 1.0.0 dev: false + /rc-align@2.4.5: + resolution: {integrity: sha512-nv9wYUYdfyfK+qskThf4BQUSIadeI/dCsfaMZfNEoxm9HwOIioQ+LyqmMK6jWHAZQgOzMLaqawhuBXlF63vgjw==} + dependencies: + babel-runtime: 6.26.0 + dom-align: 1.12.4 + prop-types: 15.8.1 + rc-util: 4.21.1 + dev: false + + /rc-animate@2.11.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-1NyuCGFJG/0Y+9RKh5y/i/AalUCA51opyyS/jO2seELpgymZm2u9QV3xwODwEuzkmeQ1BDPxMLmYLcTJedPlkQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + dependencies: + babel-runtime: 6.26.0 + classnames: 2.3.2 + css-animation: 1.6.1 + prop-types: 15.8.1 + raf: 3.4.1 + rc-util: 4.21.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-lifecycles-compat: 3.0.4 + dev: false + + /rc-calendar@9.15.11(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-qv0VXfAAnysMWJigxaP6se4bJHvr17D9qsLbi8BOpdgEocsS0RkgY1IUiFaOVYKJDy/EyLC447O02sV/y5YYBg==} + dependencies: + babel-runtime: 6.26.0 + classnames: 2.3.2 + moment: 2.29.4 + prop-types: 15.8.1 + rc-trigger: 2.6.5(react-dom@18.2.0)(react@18.2.0) + rc-util: 4.21.1 + react-lifecycles-compat: 3.0.4 + transitivePeerDependencies: + - react + - react-dom + dev: false + + /rc-trigger@2.6.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-m6Cts9hLeZWsTvWnuMm7oElhf+03GOjOLfTuU0QmdB9ZrW7jR2IpI5rpNM7i9MvAAlMAmTx5Zr7g3uu/aMvZAw==} + dependencies: + babel-runtime: 6.26.0 + classnames: 2.3.2 + prop-types: 15.8.1 + rc-align: 2.4.5 + rc-animate: 2.11.1(react-dom@18.2.0)(react@18.2.0) + rc-util: 4.21.1 + react-lifecycles-compat: 3.0.4 + transitivePeerDependencies: + - react + - react-dom + dev: false + + /rc-util@4.21.1: + resolution: {integrity: sha512-Z+vlkSQVc1l8O2UjR3WQ+XdWlhj5q9BMQNLk2iOBch75CqPfrJyGtcWMcnhRlNuDu0Ndtt4kLVO8JI8BrABobg==} + dependencies: + add-dom-event-listener: 1.1.0 + prop-types: 15.8.1 + react-is: 16.13.1 + react-lifecycles-compat: 3.0.4 + shallowequal: 1.1.0 + dev: false + /rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -20394,6 +21277,15 @@ packages: react: 18.2.0 dev: false + /react-input-autosize@3.0.0(react@18.2.0): + resolution: {integrity: sha512-nL9uS7jEs/zu8sqwFE5MAPx6pPkNAriACQ2rGLlqmKr2sPGtN7TXTyDdQt4lbNXVx7Uzadb40x8qotIuru6Rhg==} + peerDependencies: + react: ^16.3.0 || ^17.0.0 + dependencies: + prop-types: 15.8.1 + react: 18.2.0 + dev: false + /react-intl-universal@2.6.11(react@18.2.0): resolution: {integrity: sha512-myFwndeCqo+02Xm43RnO+gDltDnfYnVrm/09RzC/Ski3vjVNpyoV5edmigAjo12x+qnhuL+5zgVvqW+9BuMMBw==} requiresBuild: true @@ -20436,6 +21328,33 @@ packages: react: 18.2.0 dev: false + /react-modal@3.16.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg==} + engines: {node: '>=8'} + peerDependencies: + react: ^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 + react-dom: ^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 + dependencies: + exenv: 1.2.2 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-lifecycles-compat: 3.0.4 + warning: 4.0.3 + dev: false + + /react-plaid-link@3.5.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-OSbPVEIQY3RDroDGyimRh9vUpZfSVzKVCwrbGOSIjcmluHnPKTkvJ1BnYbvE7kH+v8urJXMHloV43uMTNY3SLg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-script-hook: 1.7.2(react-dom@18.2.0)(react@18.2.0) + dev: false + /react-popper@1.3.11(react@18.2.0): resolution: {integrity: sha512-VSA/bS+pSndSF2fiasHK/PTEEAyOpX60+H5EPAjoArr8JGm+oihu4UbrqcEBpQibJxBVCpYyjAX7abJ+7DoYVg==} peerDependencies: @@ -20578,6 +21497,16 @@ packages: tiny-warning: 1.0.3 dev: false + /react-script-hook@1.7.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-fhyCEfXb94fag34UPRF0zry1XGwmVY+79iibWwTqAoOiCzYJQOYTiWJ7CnqglA9tMSV8g45cQpHCMcBwr7dwhA==} + peerDependencies: + react: ^16.8.6 || 17 - 18 + react-dom: ^16.8.6 || 17 - 18 + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react-scripts@5.0.1(@babel/plugin-syntax-flow@7.22.5)(@babel/plugin-transform-react-jsx@7.20.13)(eslint@8.33.0)(react@18.2.0)(sass@1.68.0)(ts-node@10.9.1)(typescript@4.9.5): resolution: {integrity: sha512-8VAmEm/ZAwQzJ+GOMLbBsTdDKOpuZh7RPs0UymvBR2vRk4iZWCskjbFnxqjrzoIvlNNRZ3QJFx6/qDSi6zSnaQ==} engines: {node: '>=14.0.0'} @@ -20700,6 +21629,24 @@ packages: - react-dom dev: false + /react-select@3.2.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-B/q3TnCZXEKItO0fFN/I0tWOX3WJvi/X2wtdffmwSQVRwg5BpValScTO1vdic9AxlUgmeSzib2hAZAwIUQUZGQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 + react-dom: ^16.8.0 || ^17.0.0 + dependencies: + '@babel/runtime': 7.20.13 + '@emotion/cache': 10.0.29 + '@emotion/core': 10.3.1(react@18.2.0) + '@emotion/css': 10.0.27 + memoize-one: 5.2.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-input-autosize: 3.0.0(react@18.2.0) + react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0) + dev: false + /react-side-effect@2.1.2(react@18.2.0): resolution: {integrity: sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==} peerDependencies: @@ -20755,6 +21702,16 @@ packages: react: 18.2.0 dev: false + /react-textarea-autosize@7.1.2(react@18.2.0): + resolution: {integrity: sha512-uH3ORCsCa3C6LHxExExhF4jHoXYCQwE5oECmrRsunlspaDAbS4mGKNlWZqjLfInWtFQcf0o1n1jC/NGXFdUBCg==} + peerDependencies: + react: '>=0.14.0 <17.0.0' + dependencies: + '@babel/runtime': 7.20.13 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + /react-transition-group@2.9.0(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==} peerDependencies: @@ -21112,6 +22069,10 @@ packages: resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} dev: false + /regenerator-runtime@0.11.1: + resolution: {integrity: sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==} + dev: false + /regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} @@ -21542,6 +22503,10 @@ packages: fsevents: 2.3.2 dev: false + /rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + dev: false + /rsvp@4.8.5: resolution: {integrity: sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==} engines: {node: 6.* || >= 7.*} @@ -22111,6 +23076,58 @@ packages: - supports-color dev: false + /socket.io-adapter@2.5.4: + resolution: {integrity: sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==} + dependencies: + debug: 4.3.4(supports-color@5.5.0) + ws: 8.11.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: false + + /socket.io-client@4.7.4: + resolution: {integrity: sha512-wh+OkeF0rAVCrABWQBaEjLfb7DVPotMbu0cgWgyR0v6eA4EoVnAwcIeIbcdTE3GT/H3kbdLl7OoH2+asoDRIIg==} + engines: {node: '>=10.0.0'} + dependencies: + '@socket.io/component-emitter': 3.1.0 + debug: 4.3.4(supports-color@5.5.0) + engine.io-client: 6.5.3 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: false + + /socket.io-parser@4.2.4: + resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} + engines: {node: '>=10.0.0'} + dependencies: + '@socket.io/component-emitter': 3.1.0 + debug: 4.3.4(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + dev: false + + /socket.io@4.7.4: + resolution: {integrity: sha512-DcotgfP1Zg9iP/dH9zvAQcWrE0TtbMVwXmlV4T4mqsvY+gw+LqUGPfx2AoVyRk0FLME+GQhufDMyacFmw7ksqw==} + engines: {node: '>=10.2.0'} + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.3.4(supports-color@5.5.0) + engine.io: 6.5.4 + socket.io-adapter: 2.5.4 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: false + /sockjs@0.3.24: resolution: {integrity: sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==} dependencies: @@ -23161,6 +24178,11 @@ packages: engines: {node: '>=8'} dev: false + /throttle-debounce@3.0.1: + resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==} + engines: {node: '>=10'} + dev: false + /through2-filter@3.0.0: resolution: {integrity: sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==} dependencies: @@ -23227,6 +24249,12 @@ packages: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} dev: false + /tippy.js@6.3.7: + resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} + dependencies: + '@popperjs/core': 2.11.8 + dev: false + /tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} dependencies: @@ -23611,6 +24639,11 @@ packages: resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} engines: {node: '>=8'} + /type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + dev: false + /type-is@1.6.15: resolution: {integrity: sha512-0uqZYZDiBICTVXEsNcDLueZLPgZ8FgGe8lmVDQ0FcVFUeaxsPbFWiz60ZChVw8VELIt7iGuCehOrZSYjYteWKQ==} engines: {node: '>= 0.6'} @@ -23703,6 +24736,10 @@ packages: engines: {node: '>=4.2.0'} hasBin: true + /uc.micro@2.0.0: + resolution: {integrity: sha512-DffL94LsNOccVn4hyfRe5rdKa273swqeA5DJpMOeFmEn1wCDc7nAbbB0gXlgBCL7TNzeTv6G7XVWzan7iJtfig==} + dev: false + /uglify-js@3.17.4: resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==} engines: {node: '>=0.8.0'} @@ -24167,6 +25204,10 @@ packages: browser-process-hrtime: 1.0.0 dev: false + /w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + dev: false + /w3c-xmlserializer@1.1.2: resolution: {integrity: sha512-p10l/ayESzrBMYWRID6xbuCKh2Fp77+sA0doRuGn4tTIMrrZVeqfpKjXHY+oDh3K4nLdPgNwMTVP6Vp4pvqbNg==} dependencies: @@ -24959,6 +26000,19 @@ packages: optional: true dev: false + /ws@8.11.0: + resolution: {integrity: sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + /ws@8.14.2: resolution: {integrity: sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==} engines: {node: '>=10.0.0'} @@ -25004,6 +26058,11 @@ packages: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} dev: false + /xmlhttprequest-ssl@2.0.0: + resolution: {integrity: sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==} + engines: {node: '>=0.4.0'} + dev: false + /xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'}