diff --git a/CHANGELOG.md b/CHANGELOG.md index 84f8c3475..38171287b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,66 @@ All notable changes to Bigcapital server-side will be in this file. +# [0.22.0] + +* feat: estimate, receipt, credit note mail preview by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/757 +* feat: Add discount to transactions by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/758 +* fix: update financial forms to use new formatted amount utilities and… by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/760 +* fix: total lines style by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/761 +* fix: discount & adjustment sale transactions bugs by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/762 +* fix: discount transactions GL entries by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/763 + +# [0.21.2] + +* hotbug: upload attachments by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/755 + +# [0.21.1] + +* fix: download invoice document on payment page by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/750 +* fix: attach branding template attrs to payment page by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/751 +* fix: make manual entries adjust decimal credit/debit amounts by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/754 +* feat: allow quantity of entries accept decimal value by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/753 + +# [0.21.0] + +* fix: Credit and debit totals not balancing when decimal values are used by @nklmantey in https://github.com/bigcapitalhq/bigcapital/pull/722 +* docs: add nklmantey as a contributor for bug by @allcontributors in https://github.com/bigcapitalhq/bigcapital/pull/725 +* feat: track more services events by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/721 +* feat: Invoice mail receipt preview by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/723 +* fix: change the send mail button on invoice drawer by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/730 +* refactor: notification mail services by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/731 +* fix: attach payment link in sending invoice mail receipt by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/732 +* fix: send invoice drawer layout by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/733 +* fix: hook up cc and bcc fields to mail sender by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/734 +* fix: company logo does not show up in mail receipt preview by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/736 +* fix: change default invoice mail message by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/737 +* fix: typing invoice send mail fields by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/738 +* fix: clean up ivnoice mail receipt preview component by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/739 +* feat: add shared package to pdf templates to render in the server and… by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/735 +* feat: getting invoice preview on send mail view by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/740 +* fix: style SSR invoice paper template by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/741 +* fix: send invoice receipt addresses by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/742 +* fix: due invoice server invoice by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/744 +* fix: `BIG-265` forgot password text by @ibutiti in https://github.com/bigcapitalhq/bigcapital/pull/745 +* Crims on sv translation by @Crims-on in https://github.com/bigcapitalhq/bigcapital/pull/671 +* feat: Added Spanish language to the App 🇪🇸 by @angelosorno in https://github.com/bigcapitalhq/bigcapital/pull/530 +* fix: mail services by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/746 +* fix: company logo of the template by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/747 +* fix: monorepo dependencies scope by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/748 + +# [0.20.6] + +* fix: Import category column of item resource by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/710 +* fix: Parse the uppercase values in importing by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/711 +* chore: Move i18nApply localization to the account transformer by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/713 +* fix: Sync Plaid credit card account type by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/714 +* fix: Sync account normal of cashflow GL entries by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/715 +* feat: Add quantity column to pdf templates by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/716 +* feat: Pre-line invoice statements by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/717 +* feat: Invoice number in downloaded pdf document by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/718 +* feat: Track events of pdf documents views by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/719 +* fix: Customer note does not appear in pdf document by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/720 + # [0.20.5] * fix: Disable tabs of the pdf customization if the first field not filed up by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/701 diff --git a/packages/server/.gitignore b/packages/server/.gitignore index 1cac9af1b..1389d5314 100644 --- a/packages/server/.gitignore +++ b/packages/server/.gitignore @@ -4,5 +4,5 @@ stdout.log /dist /build /public/imports - -dist \ No newline at end of file +dist +newrelic_agent.log diff --git a/packages/server/resources/views/modules/estimate-regular.pug b/packages/server/resources/views/modules/estimate-regular.pug deleted file mode 100644 index 975436d0f..000000000 --- a/packages/server/resources/views/modules/estimate-regular.pug +++ /dev/null @@ -1,242 +0,0 @@ -extends ../PaperTemplateLayout.pug - -block head - - var prefix = 'bc' - style. - .#{prefix}-root { - color: #111; - padding: 24px 30px; - font-size: 12px; - position: relative; - box-shadow: inset 0 4px 0px 0 var(--invoice-primary-color); - } - .#{prefix}-header { - box-sizing: border-box; - display: flex; - flex-flow: wrap; - flex: 0 0 auto; - -webkit-box-align: start; - align-items: start; - -webkit-box-pack: start; - justify-content: flex-start; - gap: 10px; - } - .#{prefix}-header-details { - flex: 1; - display: flex; - flex-direction: column; - align-items: stretch; - gap: 20px; - flex: 1 1 0%; - } - .#{prefix}-big-title { - font-size: 30px; - margin: 0; - line-height: 1; - font-weight: 500; - color: #333; - } - .#{prefix}-logo-wrap img { - width: 100%; - height: 100%; - max-width: 260px; - max-height: 100px; - } - .#{prefix}-terms { - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 24px; - } - .#{prefix}-terms-item { - display: flex; - flex-direction: row; - gap: 12px; - } - .#{prefix}-terms-item__label { - min-width: 120px; - color: #333; - } - .#{prefix}-terms-item__value { - } - .#{prefix}-addresses{ - box-sizing: border-box; - display: flex; - flex-flow: wrap; - align-items: flex-start; - justify-content: flex-start; - gap: 10px; - margin-bottom: 24px; - } - .#{prefix}-addresses > * { - flex: 1 1; - } - .#{prefix}-address { - } - .#{prefix}-address__item { - } - .#{prefix}-table { - width: 100%; - border-collapse: collapse; - text-align: left; - font-size: inherit; - } - .#{prefix}-table__header { - font-weight: 400; - border-bottom: 1px solid #000; - padding: 2px 10px; - color: #333; - } - .#{prefix}-table__header:first-of-type{ - padding-left: 0; - } - .#{prefix}-table__header:last-of-type{ - padding-right: 0; - } - .#{prefix}-table__header--right { - text-align: right; - } - .#{prefix}-table__header--item{ - width: 50%; - } - .#{prefix}-table__cell { - border-bottom: 1px solid #F6F6F6; - padding: 12px 10px; - } - .#{prefix}-table__cell--right{ - text-align: right; - } - .#{prefix}-table__cell:first-of-type{ - padding-left: 0; - } - .#{prefix}-table__cell:last-of-type { - padding-right: 0; - } - .#{prefix}-table__cell--item .item { - display: flex; - flex-direction: column; - gap: 2px; - } - .#{prefix}-table__cell--item .item .item__description{ - color: #5f6b7c; - } - .#{prefix}-totals { - display: flex; - flex-direction: column; - margin-left: auto; - width: 300px; - margin-bottom: 24px; - } - .#{prefix}-totals__item { - display: flex; - padding: 4px 0; - } - .#{prefix}-totals__item--border-gray { - border-bottom: 1px solid #DADADA; - } - .#{prefix}-totals__item--border-dark { - border-bottom: 1px solid #000; - } - .#{prefix}-totals__item--font-weight-bold { - font-weight: bold; - } - .#{prefix}-totals__item-label { - min-width: 160px; - } - .#{prefix}-totals__item-amount { - flex: 1 1 auto; - text-align: right; - } - .#{prefix}-statement { - margin-bottom: 20px; - } - .#{prefix}-statement__label { - color: #666; - } - .#{prefix}-statement__value { - white-space: pre-line; - } - -block content - div(class=`${prefix}-root`, style=`--invoice-primary-color: ${primaryColor}; --invoice-secondary-color: ${secondaryColor};`) - - //- Header (invluces big title, details and logo) - div(class=`${prefix}-header`) - - //- Header details (includes big title and details ) - div(class=`${prefix}-header-details`) - h1(class=`${prefix}-big-title`) Estimate - - //- Terms List - div(class=`${prefix}-terms`) - if showEstimateNumber - div(class=`${prefix}-terms-item`) - div(class=`${prefix}-terms-item__label`) #{estimateNumberLabel} - div(class=`${prefix}-terms-item__value`) #{estimateNumebr} - - if showEstimateDate - div(class=`${prefix}-terms-item`) - div(class=`${prefix}-terms-item__label`) #{estimateDateLabel} - div(class=`${prefix}-terms-item__value`) #{estimateDate} - - if showExpirationDate - div(class=`${prefix}-terms-item`) - div(class=`${prefix}-terms-item__label`) #{expirationDateLabel} - div(class=`${prefix}-terms-item__value`) #{expirationDate} - - //- Company logo - if showCompanyLogo && companyLogoUri - div(class=`${prefix}-logo-wrap`) - img(alt="Company logo", src=companyLogoUri) - - //- Addresses (Group section) - div(class=`${prefix}-addresses`) - if showCompanyAddress - div(class=`${prefix}-address-from`) - div !{companyAddress} - - if showCustomerAddress - div(class=`${prefix}-address-to`) - strong #{billedToLabel} - div !{customerAddress} - - //- Table section (Line items) - table(class=`${prefix}-table`) - thead - tr - th(class=`${prefix}-table__header ${prefix}-table__header--item`) Item - th(class=`${prefix}-table__header ${prefix}-table__header--quantity ${prefix}-table__header--right`) Qty - th(class=`${prefix}-table__header ${prefix}-table__header--rate ${prefix}-table__header--right`) Rate - th(class=`${prefix}-table__header ${prefix}-table__header--total ${prefix}-table__header--right`) Total - tbody - each line in lines - tr - td(class=`${prefix}-table__cell ${prefix}-table__cell--item`) - div.item - div.item__label #{line.item} - div.item__description #{line.description} - td(class=`${prefix}-table__cell ${prefix}-table__cell--quantity ${prefix}-table__cell--right`) #{line.quantity} - td(class=`${prefix}-table__cell ${prefix}-table__cell--rate ${prefix}-table__cell--right`) #{line.rate} - td(class=`${prefix}-table__cell ${prefix}-table__cell--total ${prefix}-table__cell--right`) #{line.total} - - //- Totals section - div(class=`${prefix}-totals`) - if showSubtotal - div(class=`${prefix}-totals__item ${prefix}-totals__item--border-gray`) - div(class=`${prefix}-totals__item-label`) #{subtotalLabel} - div(class=`${prefix}-totals__item-amount`) #{subtotal} - if showTotal - div(class=`${prefix}-totals__item ${prefix}-totals__item--border-dark ${prefix}-totals__item--font-weight-bold`) - div(class=`${prefix}-totals__item-label`) #{totalLabel} - div(class=`${prefix}-totals__item-amount`) #{total} - - //- Statements section - if showTermsConditions && termsConditions - div(class=`${prefix}-statement`) - div(class=`${prefix}-statement__label`) #{termsConditionsLabel} - div(class=`${prefix}-statement__value`) #{termsConditions} - - if showCustomerNote && customerNote - div(class=`${prefix}-statement`) - div(class=`${prefix}-statement__label`) #{customerNoteLabel} - div(class=`${prefix}-statement__value`) #{customerNote} diff --git a/packages/server/resources/views/modules/invoice-standard.pug b/packages/server/resources/views/modules/invoice-standard.pug deleted file mode 100644 index 0435d901a..000000000 --- a/packages/server/resources/views/modules/invoice-standard.pug +++ /dev/null @@ -1,272 +0,0 @@ -extends ../PaperTemplateLayout.pug - -block head - - var prefix = 'bc' - style. - .#{prefix}-root { - color: #111; - padding: 24px 30px; - font-size: 12px; - position: relative; - box-shadow: inset 0 4px 0px 0 var(--invoice-primary-color); - } - .#{prefix}-header{ - box-sizing: border-box; - display: flex; - flex-flow: wrap; - flex: 0 0 auto; - -webkit-box-align: start; - align-items: start; - -webkit-box-pack: start; - justify-content: flex-start; - gap: 10px; - } - .#{prefix}-header-details{ - flex: 1; - display: flex; - flex-direction: column; - align-items: stretch; - gap: 20px; - flex: 1 1 0%; - } - .#{prefix}-big-title { - font-size: 30px; - margin: 0; - line-height: 1; - font-weight: 500; - color: #333; - } - .#{prefix}-logo-wrap img { - width: 100%; - height: 100%; - max-width: 260px; - max-height: 100px; - } - .#{prefix}-details { - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 24px; - } - .#{prefix}-detail { - display: flex; - flex-direction: row; - gap: 12px; - } - .#{prefix}-detail__label { - min-width: 120px; - color: #333; - } - .#{prefix}-detail__value { - /* Styles for detail values */ - } - .#{prefix}-address-root { - box-sizing: border-box; - display: flex; - flex-flow: wrap; - align-items: flex-start; - justify-content: flex-start; - gap: 10px; - margin-bottom: 24px; - } - .#{prefix}-address-from { - flex: 1; - } - .#{prefix}-address-from__item { - /* Styles for items in the billed-from address */ - } - .#{prefix}-address-to { - flex: 1; - } - .#{prefix}-address-to__item { - /* Styles for items in the billed-to address */ - } - .#{prefix}-table { - width: 100%; - border-collapse: collapse; - text-align: left; - font-size: inherit; - } - .#{prefix}-table__header { - font-weight: 400; - border-bottom: 1px solid #000; - padding: 2px 10px; - color: #333; - } - .#{prefix}-table__header:first-of-type{ - padding-left: 0; - } - .#{prefix}-table__header:last-of-type{ - padding-right: 0; - } - .#{prefix}-table__header--right { - text-align: right; - } - .#{prefix}-table__header--item { - width: 50%; - } - .#{prefix}-table__cell { - border-bottom: 1px solid #F6F6F6; - padding: 12px 10px; - } - .#{prefix}-table__cell:first-of-type{ - padding-left: 0; - } - .#{prefix}-table__cell:last-of-type { - padding-right: 0; - } - .#{prefix}-table__cell--right { - text-align: right; - } - .#{prefix}-table__cell--item .item { - display: flex; - flex-direction: column; - gap: 2px; - } - .#{prefix}-table__cell--item .item__description { - color: #5f6b7c; - } - .#{prefix}-totals { - display: flex; - flex-direction: column; - margin-left: auto; - width: 300px; - margin-bottom: 24px; - } - .#{prefix}-totals__item { - display: flex; - padding: 4px 0; - } - .#{prefix}-totals__item--border-gray { - border-bottom: 1px solid #DADADA; - } - .#{prefix}-totals__item--border-dark { - border-bottom: 1px solid #000; - } - .#{prefix}-totals__item--font-weight-bold { - font-weight: bold; - } - .#{prefix}-totals__item-label { - min-width: 160px; - } - .#{prefix}-totals__item-amount { - flex: 1 1 auto; - text-align: right; - } - .#{prefix}-paragraph { - margin-bottom: 20px; - } - .#{prefix}-paragraph__label { - color: #666; - } - .#{prefix}-paragraph__value { - white-space: pre-line; - } -block content - //- block head - div(class=`${prefix}-root`, style=`--invoice-primary-color: ${primaryColor}; --invoice-secondary-color: ${secondaryColor};`) - - //- Header (includes big title, details and logo ) - div(class=`${prefix}-header`) - //- Header details (includes big title and details ) - div(class=`${prefix}-header-details`) - //- Title and company logo - h1(class=`${prefix}-big-title`) Invoice - - //- Invoice details - div(class=`${prefix}-details`) - if showInvoiceNumber - div(class=`${prefix}-detail`) - div(class=`${prefix}-detail__label`) #{invoiceNumberLabel} - div(class=`${prefix}-detail__value`) #{invoiceNumber} - - if showDateIssue - div(class=`${prefix}-detail`) - div(class=`${prefix}-detail__label`) #{dateIssueLabel} - div(class=`${prefix}-detail__value`) #{dateIssue} - - if showDueDate - div(class=`${prefix}-detail`) - div(class=`${prefix}-detail__label`) #{dueDateLabel} - div(class=`${prefix}-detail__value`) #{dueDate} - - //- Company logo - if showCompanyLogo && companyLogoUri - div(class=`${prefix}-logo-wrap`) - img(alt="Company logo", src=companyLogoUri) - - //- Address section - div(class=`${prefix}-address-root`) - if showCompanyAddress - div(class=`${prefix}-address-from`) - div !{companyAddress} - - if showCustomerAddress - div(class=`${prefix}-address-to`) - strong #{billedToLabel} - div !{customerAddress} - - //- Invoice table - table(class=`${prefix}-table`) - thead - tr - th(class=`${prefix}-table__header ${prefix}-table__header--item`) #{lineItemLabel} - th(class=`${prefix}-table__header ${prefix}-table__header--quantity ${prefix}-table__header--right`) #{lineQuantityLabel} - th(class=`${prefix}-table__header ${prefix}-table__header--rate ${prefix}-table__header--right`) #{lineRateLabel} - th(class=`${prefix}-table__header ${prefix}-table__header--total ${prefix}-table__header--right`) #{lineTotalLabel} - tbody - each line in lines - tr - td(class=`${prefix}-table__cell ${prefix}-table__cell--item`) - div.item - div.item__label #{line.item} - div.item__description #{line.description} - td(class=`${prefix}-table__cell ${prefix}-table__cell--right`) #{line.quantity} - td(class=`${prefix}-table__cell ${prefix}-table__cell--right`) #{line.rate} - td(class=`${prefix}-table__cell ${prefix}-table__cell--right`) #{line.total} - - //- Totals section - div(class=`${prefix}-totals`) - if showSubtotal - div(class=`${prefix}-totals__item ${prefix}-totals__item--border-gray`) - div(class=`${prefix}-totals__item-label`) #{subtotalLabel} - div(class=`${prefix}-totals__item-amount`) #{subtotal} - - if showDiscount - div(class=`${prefix}-totals__item`) - div(class=`${prefix}-totals__item-label`) #{discountLabel} - div(class=`${prefix}-totals__item-amount`) #{discount} - - if showTaxes - each tax in taxes - div(class=`${prefix}-totals__item`) - div(class=`${prefix}-totals__item-label`) #{tax.label} - div(class=`${prefix}-totals__item-amount`) #{tax.amount} - - if showTotal - div(class=`${prefix}-totals__item ${prefix}-totals__item--border-dark ${prefix}-totals__item--font-weight-bold`) - div(class=`${prefix}-totals__item-label`) #{totalLabel} - div(class=`${prefix}-totals__item-amount`) #{total} - - if showPaymentMade - div(class=`${prefix}-totals__item`) - div(class=`${prefix}-totals__item-label`) #{paymentMadeLabel} - div(class=`${prefix}-totals__item-amount`) #{paymentMade} - - if showBalanceDue - div(class=`${prefix}-totals__item ${prefix}-totals__item--border-dark ${prefix}-totals__item--font-weight-bold`) - div(class=`${prefix}-totals__item-label`) #{balanceDueLabel} - div(class=`${prefix}-totals__item-amount`) #{balanceDue} - - //- Footer section - if showTermsConditions && termsConditions - div(class=`${prefix}-paragraph`) - if termsConditionsLabel - div(class=`${prefix}-paragraph__label`) #{termsConditionsLabel} - div(class=`${prefix}-paragraph__value`) #{termsConditions} - - if showStatement && statement - div(class=`${prefix}-paragraph`) - if statementLabel - div(class=`${prefix}-paragraph__label`) #{statementLabel} - div(class=`${prefix}-paragraph__value`) #{statement} diff --git a/packages/server/resources/views/modules/payment-receive-standard.pug b/packages/server/resources/views/modules/payment-receive-standard.pug deleted file mode 100644 index cf981ec4d..000000000 --- a/packages/server/resources/views/modules/payment-receive-standard.pug +++ /dev/null @@ -1,192 +0,0 @@ -extends ../PaperTemplateLayout.pug - -block head - - var prefix = 'bp3'; - - style. - .#{prefix}-root{ - color: #111; - padding: 24px 30px; - font-size: 12px; - position: relative; - box-shadow: inset 0 4px 0px 0 var(--invoice-primary-color); - } - .#{prefix}-header{ - box-sizing: border-box; - display: flex; - flex-flow: wrap; - flex: 0 0 auto; - -webkit-box-align: start; - align-items: start; - -webkit-box-pack: start; - justify-content: flex-start; - gap: 10px; - } - .#{prefix}-header-details{ - flex: 1; - display: flex; - flex-direction: column; - align-items: stretch; - gap: 20px; - flex: 1 1 0%; - } - .#{prefix}-big-title{ - font-size: 30px; - margin: 0; - line-height: 1; - font-weight: 500; - color: #333; - } - .#{prefix}-logo-wrap img { - width: 100%; - height: 100%; - max-width: 260px; - max-height: 100px; - } - .#{prefix}-terms-list{ - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 24px; - } - .#{prefix}-terms-item{ - display: flex; - flex-direction: row; - gap: 12px; - } - .#{prefix}-terms-item__label{ - min-width: 120px; - color: #333; - } - .#{prefix}-addresses{ - box-sizing: border-box; - display: flex; - flex-flow: wrap; - align-items: flex-start; - justify-content: flex-start; - gap: 10px; - margin-bottom: 24px; - } - .#{prefix}-addresses > * { - flex: 1 1; - } - .#{prefix}-address__label{ - - } - .#{prefix}-table { - width: 100%; - border-collapse: collapse; - text-align: left; - font-size: inherit; - } - .#{prefix}-table__header { - font-weight: 400; - border-bottom: 1px solid #000; - padding: 2px 10px; - color: #333; - } - .#{prefix}-table__header:first-of-type{ - padding-left: 0; - } - .#{prefix}-table__header:last-of-type{ - padding-right: 0; - } - .#{prefix}-table__header--right { - text-align: right; - } - .#{prefix}-table__cell { - border-bottom: 1px solid #F6F6F6; - padding: 12px 10px; - } - .#{prefix}-table__cell:first-of-type{ - padding-left: 0; - } - .#{prefix}-table__cell:last-of-type { - padding-right: 0; - } - .#{prefix}-table__cell--right { - text-align: right; - } - .#{prefix}-totals { - display: flex; - flex-direction: column; - margin-left: auto; - width: 300px; - margin-bottom: 24px; - } - .#{prefix}-totals__item { - display: flex; - padding: 4px 0; - } - .#{prefix}-totals__item--gray-border { - border-bottom: 1px solid #DADADA; - } - .#{prefix}-totals__item--dark-border { - border-bottom: 1px solid #000; - } - .#{prefix}-totals__item--bold { - font-weight: bold; - } - .#{prefix}-totals__item-label { - min-width: 160px; - } - .#{prefix}-totals__item-amount { - flex: 1 1 auto; - text-align: right; - } -block content - div(class=`${prefix}-root`) - //- Header (includes big title, details and logo ) - div(class=`${prefix}-header`) - //- Header details (includes big title and details ) - div(class=`${prefix}-header-details`) - div(class=`${prefix}-big-title`) Payment - div(class=`${prefix}-terms-list`) - if showPaymentReceivedNumber - div(class=`${prefix}-terms-item`) - div(class=`${prefix}-terms-item__label`) #{paymentReceivedNumberLabel} - div(class=`${prefix}-terms-item__value`) #{paymentReceivedNumebr} - - if showPaymentReceivedDate - div(class=`${prefix}-terms-item`) - div(class=`${prefix}-terms-item__label`) #{paymentReceivedDateLabel} - div(class=`${prefix}-terms-item__value`) #{paymentReceivedDate} - - if showCompanyLogo && companyLogoUri - div(class=`${prefix}-logo-wrap`) - img(src=companyLogoUri alt="Company Logo") - - div(class=`${prefix}-addresses`) - if showCompanyAddress - div(class=`${prefix}-address-from`) - div !{companyAddress} - - if showCustomerAddress - div(class=`${prefix}-address-to`) - strong #{billedToLabel} - div !{customerAddress} - - table(class=`${prefix}-table`) - thead - tr - th(class=`${prefix}-table__header`) Invoice # - th(class=`${prefix}-table__header ${prefix}-table__header--right`) Invoice Amount - th(class=`${prefix}-table__header ${prefix}-table__header--right`) Paid Amount - - tbody - each line in lines - tr - td(class=`${prefix}-table__cell`) #{line.invoiceNumber} - td(class=`${prefix}-table__cell ${prefix}-table__cell--right`) #{line.invoiceAmount} - td(class=`${prefix}-table__cell ${prefix}-table__cell--right`) #{line.paidAmount} - - div(class=`${prefix}-totals`) - if showSubtotal - div(class=`${prefix}-totals__item ${prefix}-totals__item--gray-border`) - div(class=`${prefix}-totals__item-label`) #{subtotalLabel} - div(class=`${prefix}-totals__item-amount`) #{subtotal} - - if showTotal - div(class=`${prefix}-totals__item ${prefix}-totals__item--dark-border`) - div(class=`${prefix}-totals__item-label`) #{totalLabel} - div(class=`${prefix}-totals__item-amount`) #{total} diff --git a/packages/server/resources/views/modules/receipt-regular.pug b/packages/server/resources/views/modules/receipt-regular.pug deleted file mode 100644 index dfce3b5b5..000000000 --- a/packages/server/resources/views/modules/receipt-regular.pug +++ /dev/null @@ -1,231 +0,0 @@ -extends ../PaperTemplateLayout.pug - -block head - - var prefix = 'bc' - style. - .#{prefix}-root { - color: #000; - padding: 24px 30px; - font-size: 12px; - position: relative; - box-shadow: inset 0 4px 0px 0 var(--invoice-primary-color); - } - .#{prefix}-header{ - box-sizing: border-box; - display: flex; - flex-flow: wrap; - flex: 0 0 auto; - align-items: start; - justify-content: flex-start; - gap: 10px; - } - .#{prefix}-header-details{ - flex: 1; - display: flex; - flex-direction: column; - align-items: stretch; - gap: 20px; - flex: 1 1 0%; - } - .#{prefix}-logo-wrap img { - width: 100%; - height: 100%; - max-width: 260px; - max-height: 100px; - } - .#{prefix}-big-title { - font-size: 30px; - margin: 0; - line-height: 1; - font-weight: 500; - color: #333; - } - .#{prefix}-terms-list { - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 24px; - } - .#{prefix}-terms-item { - display: flex; - flex-direction: row; - gap: 12px; - } - .#{prefix}-terms-item__label { - min-width: 120px; - color: #333; - } - .#{prefix}-terms-item__value {} - .#{prefix}-address-section { - box-sizing: border-box; - display: flex; - flex-flow: wrap; - -webkit-box-align: flex-start; - align-items: flex-start; - -webkit-box-pack: start; - justify-content: flex-start; - gap: 10px; - margin-bottom: 24px; - } - .#{prefix}-address-section > * { - flex: 1 1 auto; - } - .#{prefix}-address {} - .#{prefix}-table { - width: 100%; - border-collapse: collapse; - text-align: left; - font-size: inherit; - } - .#{prefix}-table__header { - font-weight: 400; - border-bottom: 1px solid #000; - padding: 2px 10px; - color: #333; - } - .#{prefix}-table__header:first-of-type{ - padding-left: 0; - } - .#{prefix}-table__header:last-of-type{ - padding-right: 0; - } - .#{prefix}-table__header--right { - text-align: right; - } - .#{prefix}-table__header--item{ - width: 50%; - } - .#{prefix}-table__cell { - border-bottom: 1px solid #F6F6F6; - padding: 12px 10px; - } - .#{prefix}-table__cell:first-of-type{ - padding-left: 0; - } - .#{prefix}-table__cell:last-of-type { - padding-right: 0; - } - .#{prefix}-table__cell--right { - text-align: right; - } - .#{prefix}-table__cell--item .item { - display: flex; - flex-direction: column; - gap: 2px; - } - .#{prefix}-table__cell--item .item .item__description{ - color: #5f6b7c; - } - .#{prefix}-totals { - display: flex; - flex-direction: column; - margin-left: auto; - width: 300px; - margin-bottom: 24px; - } - .#{prefix}-totals__line { - display: flex; - padding: 4px 0; - } - .#{prefix}-totals__line--gray-border { - border-bottom: 1px solid #DADADA; - } - .#{prefix}-totals__line--dark-border { - border-bottom: 1px solid #000; - } - .#{prefix}-totals__line__label { - min-width: 160px; - } - .#{prefix}-totals__line__amount { - flex: 1 1 auto; - text-align: right; - } - .#{prefix}-statement { - margin-bottom: 20px; - } - .#{prefix}-statement__label {} - .#{prefix}-statement__value { - white-space: pre-line; - } - -block content - //- block head - div(class=`${prefix}-root`, style=`--invoice-primary-color: ${primaryColor}; --invoice-secondary-color: ${secondaryColor};`) - - //- Header (includes big title, details and logo ) - div(class=`${prefix}-header`) - //- Header details (includes big title and details ) - div(class=`${prefix}-header-details`) - //- Title and company logo - h1(class=`${prefix}-big-title`) Receipt - - //- Terms List - div(class=`${prefix}-terms-list`) - if showReceiptNumber - div(class=`${prefix}-terms-item`) - span(class=`${prefix}-terms-item__label`)= receiptNumberLabel - span(class=`${prefix}-terms-item__value`)= receiptNumber - - if showReceiptDate - div(class=`${prefix}-terms-item`) - span(class=`${prefix}-terms-item__label`)= receiptDateLabel - span(class=`${prefix}-terms-item__value`)= receiptDate - - //- Company logo - if showCompanyLogo && companyLogoUri - div(class=`${prefix}-logo-wrap`) - img(src=companyLogoUri alt=`Company Logo`) - - //- Address Section - div(class=`${prefix}-address-section`) - if showCompanyAddress - div(class=`${prefix}-address-from`) - div !{companyAddress} - - if showCustomerAddress - div(class=`${prefix}-address-to`) - strong #{billedToLabel} - div !{customerAddress} - - //- Table Section - table(class=`${prefix}-table`) - thead(class=`${prefix}-table__header`) - tr - th(class=`${prefix}-table__header ${prefix}-table__header--item`) Item - th(class=`${prefix}-table__header ${prefix}-table__header--quantity ${prefix}-table__header--right`) Qty - th(class=`${prefix}-table__header ${prefix}-table__header--rate ${prefix}-table__header--right`) Rate - th(class=`${prefix}-table__header ${prefix}-table__header--total ${prefix}-table__header--right`) Total - tbody - each line in lines - tr(class=`${prefix}-table__row`) - td(class=`${prefix}-table__cell ${prefix}-table__cell--item`) - div.item - div.item__label #{line.item} - div.item__description #{line.description} - td(class=`${prefix}-table__cell ${prefix}-table__cell--right`) #{line.quantity} - td(class=`${prefix}-table__cell ${prefix}-table__cell--right`) #{line.rate} - td(class=`${prefix}-table__cell ${prefix}-table__cell--right`) #{line.total} - - //- Totals Section - div(class=`${prefix}-totals`) - if showSubtotal - div(class=`${prefix}-totals__line ${prefix}-totals__line--gray-border`) - span(class=`${prefix}-totals__line__label`)= subtotalLabel - span(class=`${prefix}-totals__line__amount`)= subtotal - - if showTotal - div(class=`${prefix}-totals__line ${prefix}-totals__line--dark-border`) - span(class=`${prefix}-totals__line__label`)= totalLabel - span(class=`${prefix}-totals__line__amount`)= total - - //- Customer Note Section - if showCustomerNote && customerNote - div(class=`${prefix}-statement`) - div(class=`${prefix}-statement__label`)= customerNoteLabel - div(class=`${prefix}-statement__value`)= customerNote - - //- Terms & Conditions Section - if showTermsConditions && termsConditions - div(class=`${prefix}-statement`) - div(class=`${prefix}-statement__label`)= termsConditionsLabel - div(class=`${prefix}-statement__value`)= termsConditions diff --git a/packages/server/src/api/controllers/Purchases/Bills.ts b/packages/server/src/api/controllers/Purchases/Bills.ts index 7248c97df..ec901247e 100644 --- a/packages/server/src/api/controllers/Purchases/Bills.ts +++ b/packages/server/src/api/controllers/Purchases/Bills.ts @@ -4,6 +4,7 @@ import { check, param, query } from 'express-validator'; import { AbilitySubject, BillAction, + DiscountType, IBillDTO, IBillEditDTO, } from '@/interfaces'; @@ -121,11 +122,16 @@ export default class BillsController extends BaseController { check('entries.*.index').exists().isNumeric().toInt(), check('entries.*.item_id').exists().isNumeric().toInt(), check('entries.*.rate').exists().isNumeric().toFloat(), - check('entries.*.quantity').exists().isNumeric().toInt(), + check('entries.*.quantity').exists().isNumeric().toFloat(), check('entries.*.discount') .optional({ nullable: true }) .isNumeric() .toFloat(), + check('entries.*.discount_type') + .default(DiscountType.Percentage) + .isString() + .isIn([DiscountType.Percentage, DiscountType.Amount]), + check('entries.*.description').optional({ nullable: true }).trim(), check('entries.*.landed_cost') .optional({ nullable: true }) @@ -144,8 +150,18 @@ export default class BillsController extends BaseController { .isNumeric() .toInt(), + // Attachments check('attachments').isArray().optional(), check('attachments.*.key').exists().isString(), + + // # Discount + check('discount_type') + .default(DiscountType.Amount) + .isIn([DiscountType.Amount, DiscountType.Percentage]), + check('discount').optional({ nullable: true }).isDecimal().toFloat(), + + // # Adjustment + check('adjustment').optional({ nullable: true }).isNumeric().toFloat(), ]; } @@ -188,6 +204,15 @@ export default class BillsController extends BaseController { check('attachments').isArray().optional(), check('attachments.*.key').exists().isString(), + + // # Discount + check('discount_type') + .default(DiscountType.Amount) + .isIn([DiscountType.Amount, DiscountType.Percentage]), + check('discount').optional({ nullable: true }).isDecimal().toFloat(), + + // # Adjustment + check('adjustment').optional({ nullable: true }).isNumeric().toFloat(), ]; } diff --git a/packages/server/src/api/controllers/Purchases/VendorCredit.ts b/packages/server/src/api/controllers/Purchases/VendorCredit.ts index 26e3f85b9..dd8bdae7d 100644 --- a/packages/server/src/api/controllers/Purchases/VendorCredit.ts +++ b/packages/server/src/api/controllers/Purchases/VendorCredit.ts @@ -3,6 +3,7 @@ import { check, param, query } from 'express-validator'; import { Service, Inject } from 'typedi'; import { AbilitySubject, + DiscountType, IVendorCreditCreateDTO, IVendorCreditEditDTO, VendorCreditAction, @@ -170,11 +171,15 @@ export default class VendorCreditController extends BaseController { check('entries.*.index').exists().isNumeric().toInt(), check('entries.*.item_id').exists().isNumeric().toInt(), check('entries.*.rate').exists().isNumeric().toFloat(), - check('entries.*.quantity').exists().isNumeric().toInt(), + check('entries.*.quantity').exists().isNumeric().toFloat(), check('entries.*.discount') .optional({ nullable: true }) .isNumeric() .toFloat(), + check('entries.*.discount_type') + .default(DiscountType.Percentage) + .isString() + .isIn([DiscountType.Percentage, DiscountType.Amount]), check('entries.*.description').optional({ nullable: true }).trim(), check('entries.*.warehouse_id') .optional({ nullable: true }) @@ -183,6 +188,16 @@ export default class VendorCreditController extends BaseController { check('attachments').isArray().optional(), check('attachments.*.key').exists().isString(), + + // Discount. + check('discount').optional({ nullable: true }).isNumeric().toFloat(), + check('discount_type') + .optional({ nullable: true }) + .isString() + .isIn([DiscountType.Percentage, DiscountType.Amount]), + + // Adjustment. + check('adjustment').optional({ nullable: true }).isNumeric().toFloat(), ]; } @@ -209,11 +224,15 @@ export default class VendorCreditController extends BaseController { check('entries.*.index').exists().isNumeric().toInt(), check('entries.*.item_id').exists().isNumeric().toInt(), check('entries.*.rate').exists().isNumeric().toFloat(), - check('entries.*.quantity').exists().isNumeric().toInt(), + check('entries.*.quantity').exists().isNumeric().toFloat(), check('entries.*.discount') .optional({ nullable: true }) .isNumeric() .toFloat(), + check('entries.*.discount_type') + .default(DiscountType.Percentage) + .isString() + .isIn([DiscountType.Percentage, DiscountType.Amount]), check('entries.*.description').optional({ nullable: true }).trim(), check('entries.*.warehouse_id') .optional({ nullable: true }) @@ -222,6 +241,16 @@ export default class VendorCreditController extends BaseController { check('attachments').isArray().optional(), check('attachments.*.key').exists().isString(), + + // Discount. + check('discount').optional({ nullable: true }).isNumeric().toFloat(), + check('discount_type') + .optional({ nullable: true }) + .isString() + .isIn([DiscountType.Percentage, DiscountType.Amount]), + + // Adjustment. + check('adjustment').optional({ nullable: true }).isNumeric().toFloat(), ]; } diff --git a/packages/server/src/api/controllers/Sales/CreditNotes.ts b/packages/server/src/api/controllers/Sales/CreditNotes.ts index 3037d33f1..fa1a90b27 100644 --- a/packages/server/src/api/controllers/Sales/CreditNotes.ts +++ b/packages/server/src/api/controllers/Sales/CreditNotes.ts @@ -4,6 +4,7 @@ import { Inject, Service } from 'typedi'; import { AbilitySubject, CreditNoteAction, + DiscountType, ICreditNoteEditDTO, ICreditNoteNewDTO, } from '@/interfaces'; @@ -233,22 +234,37 @@ export default class PaymentReceivesController extends BaseController { check('entries.*.index').exists().isNumeric().toInt(), check('entries.*.item_id').exists().isNumeric().toInt(), check('entries.*.rate').exists().isNumeric().toFloat(), - check('entries.*.quantity').exists().isNumeric().toInt(), + check('entries.*.quantity').exists().isNumeric().toFloat(), check('entries.*.discount') .optional({ nullable: true }) .isNumeric() .toFloat(), + check('entries.*.discount_type') + .default(DiscountType.Percentage) + .isString() + .isIn([DiscountType.Percentage, DiscountType.Amount]), check('entries.*.description').optional({ nullable: true }).trim(), check('entries.*.warehouse_id') .optional({ nullable: true }) .isNumeric() .toInt(), + // Attachments. check('attachments').isArray().optional(), check('attachments.*.key').exists().isString(), // Pdf template id. check('pdf_template_id').optional({ nullable: true }).isNumeric().toInt(), + + // Discount. + check('discount').optional({ nullable: true }).isNumeric().toFloat(), + check('discount_type') + .optional({ nullable: true }) + .isString() + .isIn([DiscountType.Percentage, DiscountType.Amount]), + + // Adjustment. + check('adjustment').optional({ nullable: true }).isNumeric().toFloat(), ]; } diff --git a/packages/server/src/api/controllers/Sales/PaymentReceives.ts b/packages/server/src/api/controllers/Sales/PaymentReceives.ts index b0f17bfac..f61768629 100644 --- a/packages/server/src/api/controllers/Sales/PaymentReceives.ts +++ b/packages/server/src/api/controllers/Sales/PaymentReceives.ts @@ -130,9 +130,18 @@ export default class PaymentReceivesController extends BaseController { [ ...this.paymentReceiveValidation, body('subject').isString().optional(), + body('from').isString().optional(), - body('to').isString().optional(), - body('body').isString().optional(), + + body('to').isArray().exists(), + body('to.*').isString().isEmail().optional(), + + body('cc').isArray().optional({ nullable: true }), + body('cc.*').isString().isEmail().optional(), + + body('bcc').isArray().optional({ nullable: true }), + body('bcc.*').isString().isEmail().optional(), + body('attach_invoice').optional().isBoolean().toBoolean(), ], this.sendPaymentReceiveByMail.bind(this), @@ -470,8 +479,9 @@ export default class PaymentReceivesController extends BaseController { const acceptType = accept.types([ ACCEPT_TYPE.APPLICATION_JSON, ACCEPT_TYPE.APPLICATION_PDF, + ACCEPT_TYPE.APPLICATION_TEXT_HTML, ]); - // Response in pdf format. + // Responds pdf format. if (ACCEPT_TYPE.APPLICATION_PDF === acceptType) { const [pdfContent, filename] = await this.paymentReceiveApplication.getPaymentReceivePdf( @@ -484,7 +494,15 @@ export default class PaymentReceivesController extends BaseController { 'Content-Disposition': `attachment; filename="${filename}"`, }); res.send(pdfContent); - // Response in json format. + // Responds html format. + } else if (ACCEPT_TYPE.APPLICATION_TEXT_HTML === acceptType) { + const htmlContent = + await this.paymentReceiveApplication.getPaymentReceivedHtml( + tenantId, + paymentReceiveId + ); + return res.status(200).send({ htmlContent }); + // Responds json format. } else { const paymentReceive = await this.paymentReceiveApplication.getPaymentReceive( diff --git a/packages/server/src/api/controllers/Sales/SalesEstimates.ts b/packages/server/src/api/controllers/Sales/SalesEstimates.ts index 556e96fd6..1c784a1f0 100644 --- a/packages/server/src/api/controllers/Sales/SalesEstimates.ts +++ b/packages/server/src/api/controllers/Sales/SalesEstimates.ts @@ -3,6 +3,7 @@ import { body, check, param, query } from 'express-validator'; import { Inject, Service } from 'typedi'; import { AbilitySubject, + DiscountType, ISaleEstimateDTO, SaleEstimateAction, SaleEstimateMailOptionsDTO, @@ -13,11 +14,8 @@ import DynamicListingService from '@/services/DynamicListing/DynamicListService' import { ServiceError } from '@/exceptions'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { SaleEstimatesApplication } from '@/services/Sales/Estimates/SaleEstimatesApplication'; +import { ACCEPT_TYPE } from '@/interfaces/Http'; -const ACCEPT_TYPE = { - APPLICATION_PDF: 'application/pdf', - APPLICATION_JSON: 'application/json', -}; @Service() export default class SalesEstimatesController extends BaseController { @Inject() @@ -133,8 +131,18 @@ export default class SalesEstimatesController extends BaseController { [ ...this.validateSpecificEstimateSchema, body('subject').isString().optional(), + body('from').isString().optional(), - body('to').isString().optional(), + + body('to').isArray().exists(), + body('to.*').isString().isEmail().optional(), + + body('cc').isArray().optional({ nullable: true }), + body('cc.*').isString().isEmail().optional(), + + body('bcc').isArray().optional({ nullable: true }), + body('bcc.*').isString().isEmail().optional(), + body('body').isString().optional(), body('attach_invoice').optional().isBoolean().toBoolean(), ], @@ -143,10 +151,10 @@ export default class SalesEstimatesController extends BaseController { this.handleServiceErrors ); router.get( - '/:id/mail', + '/:id/mail/state', [...this.validateSpecificEstimateSchema], this.validationResult, - asyncMiddleware(this.getSaleEstimateMail.bind(this)), + asyncMiddleware(this.getSaleEstimateMailState.bind(this)), this.handleServiceErrors ); return router; @@ -172,13 +180,18 @@ export default class SalesEstimatesController extends BaseController { check('entries').exists().isArray({ min: 1 }), check('entries.*.index').exists().isNumeric().toInt(), check('entries.*.item_id').exists().isNumeric().toInt(), - check('entries.*.quantity').exists().isNumeric().toInt(), + check('entries.*.quantity').exists().isNumeric().toFloat(), check('entries.*.rate').exists().isNumeric().toFloat(), check('entries.*.description').optional({ nullable: true }).trim(), check('entries.*.discount') .optional({ nullable: true }) .isNumeric() .toFloat(), + check('entries.*.discount_type') + .default(DiscountType.Percentage) + .isString() + .isIn([DiscountType.Percentage, DiscountType.Amount]), + check('entries.*.warehouse_id') .optional({ nullable: true }) .isNumeric() @@ -188,11 +201,21 @@ export default class SalesEstimatesController extends BaseController { check('terms_conditions').optional().trim(), check('send_to_email').optional().trim(), + // # Attachments check('attachments').isArray().optional(), check('attachments.*.key').exists().isString(), - // Pdf template id. + // # Pdf template id. check('pdf_template_id').optional({ nullable: true }).isNumeric().toInt(), + + // # Discount + check('discount').optional({ nullable: true }).isNumeric().toFloat(), + check('discount_type') + .default(DiscountType.Amount) + .isIn([DiscountType.Amount, DiscountType.Percentage]), + + // # Adjustment + check('adjustment').optional({ nullable: true }).isNumeric().toFloat(), ]; } @@ -395,6 +418,7 @@ export default class SalesEstimatesController extends BaseController { const acceptType = accept.types([ ACCEPT_TYPE.APPLICATION_JSON, ACCEPT_TYPE.APPLICATION_PDF, + ACCEPT_TYPE.APPLICATION_TEXT_HTML, ]); // Retrieves estimate in pdf format. if (ACCEPT_TYPE.APPLICATION_PDF == acceptType) { @@ -410,7 +434,14 @@ export default class SalesEstimatesController extends BaseController { }); res.send(pdfContent); // Retrieves estimates in json format. - } else { + } else if (ACCEPT_TYPE.APPLICATION_TEXT_HTML === acceptType) { + const htmlContent = + await this.saleEstimatesApplication.getSaleEstimateHtml( + tenantId, + estimateId + ); + return res.status(200).send({ htmlContent }); + } else if (ACCEPT_TYPE.APPLICATION_JSON) { const estimate = await this.saleEstimatesApplication.getSaleEstimate( tenantId, estimateId @@ -535,18 +566,18 @@ export default class SalesEstimatesController extends BaseController { * @param {Response} res * @param {NextFunction} next */ - private getSaleEstimateMail = async ( - req: Request, + private getSaleEstimateMailState = async ( + req: Request<{ id: number }>, res: Response, next: NextFunction ) => { const { tenantId } = req; - const { id: invoiceId } = req.params; + const { id: estimateId } = req.params; try { - const data = await this.saleEstimatesApplication.getSaleEstimateMail( + const data = await this.saleEstimatesApplication.getSaleEstimateMailState( tenantId, - invoiceId + estimateId ); return res.status(200).send({ data }); } catch (error) { diff --git a/packages/server/src/api/controllers/Sales/SalesInvoices.ts b/packages/server/src/api/controllers/Sales/SalesInvoices.ts index 69b00a576..dbe7679df 100644 --- a/packages/server/src/api/controllers/Sales/SalesInvoices.ts +++ b/packages/server/src/api/controllers/Sales/SalesInvoices.ts @@ -11,6 +11,7 @@ import { SaleInvoiceAction, AbilitySubject, SendInvoiceMailDTO, + DiscountType, } from '@/interfaces'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { SaleInvoiceApplication } from '@/services/Sales/Invoices/SaleInvoicesApplication'; @@ -242,6 +243,10 @@ export default class SaleInvoicesController extends BaseController { .optional({ nullable: true }) .isNumeric() .toFloat(), + check('entries.*.discount_type') + .default(DiscountType.Percentage) + .isString() + .isIn([DiscountType.Percentage, DiscountType.Amount]), check('entries.*.description').optional({ nullable: true }).trim(), check('entries.*.tax_code') .optional({ nullable: true }) @@ -281,6 +286,16 @@ export default class SaleInvoicesController extends BaseController { check('payment_methods').optional({ nullable: true }).isArray(), check('payment_methods.*.payment_integration_id').exists().toInt(), check('payment_methods.*.enable').exists().isBoolean(), + + // Discount + check('discount').optional({ nullable: true }).isNumeric().toFloat(), + check('discount_type') + .optional({ nullable: true }) + .isString() + .isIn([DiscountType.Percentage, DiscountType.Amount]), + + // Adjustments + check('adjustment').optional({ nullable: true }).isNumeric().toFloat(), ]; } diff --git a/packages/server/src/api/controllers/Sales/SalesReceipts.ts b/packages/server/src/api/controllers/Sales/SalesReceipts.ts index caf93d02a..47e03d03f 100644 --- a/packages/server/src/api/controllers/Sales/SalesReceipts.ts +++ b/packages/server/src/api/controllers/Sales/SalesReceipts.ts @@ -11,7 +11,7 @@ import { import { ServiceError } from '@/exceptions'; import DynamicListingService from '@/services/DynamicListing/DynamicListService'; import CheckPolicies from '@/api/middleware/CheckPolicies'; -import { AbilitySubject, SaleReceiptAction } from '@/interfaces'; +import { AbilitySubject, DiscountType, SaleReceiptAction } from '@/interfaces'; import { SaleReceiptApplication } from '@/services/Sales/Receipts/SaleReceiptApplication'; import { ACCEPT_TYPE } from '@/interfaces/Http'; @@ -56,8 +56,18 @@ export default class SalesReceiptsController extends BaseController { [ ...this.specificReceiptValidationSchema, body('subject').isString().optional(), + body('from').isString().optional(), - body('to').isString().optional(), + + body('to').isArray().exists(), + body('to.*').isString().isEmail().optional(), + + body('cc').isArray().optional({ nullable: true }), + body('cc.*').isString().isEmail().optional(), + + body('bcc').isArray().optional({ nullable: true }), + body('bcc.*').isString().isEmail().optional(), + body('body').isString().optional(), body('attach_receipt').optional().isBoolean().toBoolean(), ], @@ -148,12 +158,17 @@ export default class SalesReceiptsController extends BaseController { check('entries.*.id').optional({ nullable: true }).isNumeric().toInt(), check('entries.*.index').exists().isNumeric().toInt(), check('entries.*.item_id').exists().isNumeric().toInt(), - check('entries.*.quantity').exists().isNumeric().toInt(), + check('entries.*.quantity').exists().isNumeric().toFloat(), check('entries.*.rate').exists().isNumeric().toFloat(), check('entries.*.discount') .optional({ nullable: true }) .isNumeric() .toInt(), + check('entries.*.discount_type') + .default(DiscountType.Percentage) + .isString() + .isIn([DiscountType.Percentage, DiscountType.Amount]), + check('entries.*.description').optional({ nullable: true }).trim(), check('entries.*.warehouse_id') .optional({ nullable: true }) @@ -166,8 +181,18 @@ export default class SalesReceiptsController extends BaseController { check('attachments').isArray().optional(), check('attachments.*.key').exists().isString(), - // Pdf template id. + // # Pdf template check('pdf_template_id').optional({ nullable: true }).isNumeric().toInt(), + + // # Discount + check('discount').optional({ nullable: true }).isNumeric().toFloat(), + check('discount_type') + .optional({ nullable: true }) + .isString() + .isIn([DiscountType.Percentage, DiscountType.Amount]), + + // # Adjustment + check('adjustment').optional({ nullable: true }).isNumeric().toFloat(), ]; } @@ -353,6 +378,7 @@ export default class SalesReceiptsController extends BaseController { const acceptType = accept.types([ ACCEPT_TYPE.APPLICATION_JSON, ACCEPT_TYPE.APPLICATION_PDF, + ACCEPT_TYPE.APPLICATION_TEXT_HTML, ]); // Retrieves receipt in pdf format. if (ACCEPT_TYPE.APPLICATION_PDF == acceptType) { @@ -368,6 +394,12 @@ export default class SalesReceiptsController extends BaseController { }); res.send(pdfContent); // Retrieves receipt in json format. + } else if (ACCEPT_TYPE.APPLICATION_TEXT_HTML === acceptType) { + const htmlContent = await this.saleReceiptsApplication.getSaleReceiptHtml( + tenantId, + saleReceiptId + ); + res.send({ htmlContent }); } else { const saleReceipt = await this.saleReceiptsApplication.getSaleReceipt( tenantId, @@ -507,7 +539,7 @@ export default class SalesReceiptsController extends BaseController { const { id: receiptId } = req.params; try { - const data = await this.saleReceiptsApplication.getSaleReceiptMail( + const data = await this.saleReceiptsApplication.getSaleReceiptMailState( tenantId, receiptId ); diff --git a/packages/server/src/database/migrations/20241113113437_change_quantity_in_items_entries_to_decimal.js b/packages/server/src/database/migrations/20241113113437_change_quantity_in_items_entries_to_decimal.js new file mode 100644 index 000000000..dcc711ffc --- /dev/null +++ b/packages/server/src/database/migrations/20241113113437_change_quantity_in_items_entries_to_decimal.js @@ -0,0 +1,39 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .table('items_entries', (table) => { + table.decimal('quantity', 13, 3).alter(); + }) + .table('inventory_transactions', (table) => { + table.decimal('quantity', 13, 3).alter(); + }) + .table('inventory_cost_lot_tracker', (table) => { + table.decimal('quantity', 13, 3).alter(); + }) + .table('items', (table) => { + table.decimal('quantityOnHand', 13, 3).alter(); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema + .table('items_entries', (table) => { + table.integer('quantity').alter(); + }) + .table('inventory_transactions', (table) => { + table.integer('quantity').alter(); + }) + .table('inventory_cost_lot_tracker', (table) => { + table.integer('quantity').alter(); + }) + .table('items', (table) => { + table.integer('quantityOnHand').alter(); + }); +}; diff --git a/packages/server/src/database/migrations/20241128080734_add_discount_to_invoices_table.js b/packages/server/src/database/migrations/20241128080734_add_discount_to_invoices_table.js new file mode 100644 index 000000000..d65b00615 --- /dev/null +++ b/packages/server/src/database/migrations/20241128080734_add_discount_to_invoices_table.js @@ -0,0 +1,23 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.alterTable('sales_invoices', (table) => { + table.decimal('discount', 10, 2).nullable().after('credited_amount'); + table.string('discount_type').nullable().after('discount'); + table.decimal('adjustment', 10, 2).nullable().after('discount_type'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('sale_invoices', (table) => { + table.dropColumn('discount'); + table.dropColumn('discount_type'); + table.dropColumn('adjustment'); + }); +}; diff --git a/packages/server/src/database/migrations/20241128081259_add_discount_to_estimates_table.js b/packages/server/src/database/migrations/20241128081259_add_discount_to_estimates_table.js new file mode 100644 index 000000000..748483f26 --- /dev/null +++ b/packages/server/src/database/migrations/20241128081259_add_discount_to_estimates_table.js @@ -0,0 +1,24 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function(knex) { + return knex.schema.alterTable('sales_estimates', (table) => { + table.decimal('discount', 10, 2).nullable().after('amount'); + table.string('discount_type').nullable().after('discount'); + + table.decimal('adjustment', 10, 2).nullable().after('discount_type'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function(knex) { + return knex.schema.alterTable('sales_estimates', (table) => { + table.dropColumn('discount'); + table.dropColumn('discount_type'); + table.dropColumn('adjustment'); + }); +}; diff --git a/packages/server/src/database/migrations/20241128084550_add_discount_to_receipts_table.js b/packages/server/src/database/migrations/20241128084550_add_discount_to_receipts_table.js new file mode 100644 index 000000000..8b46955cc --- /dev/null +++ b/packages/server/src/database/migrations/20241128084550_add_discount_to_receipts_table.js @@ -0,0 +1,24 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function(knex) { + return knex.schema.alterTable('sales_receipts', (table) => { + table.decimal('discount', 10, 2).nullable().after('amount'); + table.string('discount_type').nullable().after('discount'); + + table.decimal('adjustment', 10, 2).nullable().after('discount_type'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function(knex) { + return knex.schema.alterTable('sales_receipts', (table) => { + table.dropColumn('discount'); + table.dropColumn('discount_type'); + table.dropColumn('adjustment'); + }); +}; diff --git a/packages/server/src/database/migrations/20241128085243_add_discount_to_bills_table.js b/packages/server/src/database/migrations/20241128085243_add_discount_to_bills_table.js new file mode 100644 index 000000000..9de24d9ab --- /dev/null +++ b/packages/server/src/database/migrations/20241128085243_add_discount_to_bills_table.js @@ -0,0 +1,26 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function(knex) { + return knex.schema.alterTable('bills', (table) => { + // Discount. + table.decimal('discount', 10, 2).nullable().after('amount'); + table.string('discount_type').nullable().after('discount'); + + // Adjustment. + table.decimal('adjustment', 10, 2).nullable().after('discount_type'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function(knex) { + return knex.schema.alterTable('bills', (table) => { + table.dropColumn('discount'); + table.dropColumn('discount_type'); + table.dropColumn('adjustment'); + }); +}; diff --git a/packages/server/src/database/migrations/20241128090222_add_discount_to_credit_notes_table.js b/packages/server/src/database/migrations/20241128090222_add_discount_to_credit_notes_table.js new file mode 100644 index 000000000..fcca5bfa4 --- /dev/null +++ b/packages/server/src/database/migrations/20241128090222_add_discount_to_credit_notes_table.js @@ -0,0 +1,23 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function(knex) { + return knex.schema.alterTable('credit_notes', (table) => { + table.decimal('discount', 10, 2).nullable().after('exchange_rate'); + table.string('discount_type').nullable().after('discount'); + table.decimal('adjustment', 10, 2).nullable().after('discount_type'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function(knex) { + return knex.schema.alterTable('credit_notes', (table) => { + table.dropColumn('discount'); + table.dropColumn('discount_type'); + table.dropColumn('adjustment'); + }); +}; diff --git a/packages/server/src/database/migrations/20241128160604_add_discount_to_vendor_credits_table.js b/packages/server/src/database/migrations/20241128160604_add_discount_to_vendor_credits_table.js new file mode 100644 index 000000000..706bad1f7 --- /dev/null +++ b/packages/server/src/database/migrations/20241128160604_add_discount_to_vendor_credits_table.js @@ -0,0 +1,23 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function(knex) { + return knex.schema.alterTable('vendor_credits', (table) => { + table.decimal('discount', 10, 2).nullable().after('amount'); + table.string('discount_type').nullable().after('discount'); + table.decimal('adjustment', 10, 2).nullable().after('discount_type'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function(knex) { + return knex.schema.alterTable('vendor_credits', (table) => { + table.dropColumn('discount'); + table.dropColumn('discount_type'); + table.dropColumn('adjustment'); + }); +}; diff --git a/packages/server/src/database/migrations/20241211103019_add_discount_type_to_items_entries_table.js b/packages/server/src/database/migrations/20241211103019_add_discount_type_to_items_entries_table.js new file mode 100644 index 000000000..292bdd8dc --- /dev/null +++ b/packages/server/src/database/migrations/20241211103019_add_discount_type_to_items_entries_table.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.alterTable('items_entries', (table) => { + table.string('discount_type').defaultTo('percentage').after('discount'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('items_entries', (table) => { + table.dropColumn('discount_type'); + }); +}; diff --git a/packages/server/src/database/seeds/data/accounts.js b/packages/server/src/database/seeds/data/accounts.js index 5d21686c3..35430bbed 100644 --- a/packages/server/src/database/seeds/data/accounts.js +++ b/packages/server/src/database/seeds/data/accounts.js @@ -1,3 +1,14 @@ +export const OtherExpensesAccount = { + name: 'Other Expenses', + slug: 'other-expenses', + account_type: 'other-expense', + code: '40011', + description: '', + active: 1, + index: 1, + predefined: 1, +}; + export const TaxPayableAccount = { name: 'Tax Payable', slug: 'tax-payable', @@ -39,8 +50,38 @@ export const StripeClearingAccount = { code: '100020', active: true, index: 1, - predefined: true, -} + predefined: true, +}; + +export const DiscountExpenseAccount = { + name: 'Discount', + slug: 'discount', + account_type: 'other-income', + code: '40008', + active: true, + index: 1, + predefined: true, +}; + +export const PurchaseDiscountAccount = { + name: 'Purchase Discount', + slug: 'purchase-discount', + account_type: 'other-expense', + code: '40009', + active: true, + index: 1, + predefined: true, +}; + +export const OtherChargesAccount = { + name: 'Other Charges', + slug: 'other-charges', + account_type: 'other-income', + code: '40010', + active: true, + index: 1, + predefined: true, +}; export default [ { @@ -231,17 +272,7 @@ export default [ }, // Expenses - { - name: 'Other Expenses', - slug: 'other-expenses', - account_type: 'other-expense', - parent_account_id: null, - code: '40001', - description: '', - active: 1, - index: 1, - predefined: 1, - }, + OtherExpensesAccount, { name: 'Cost of Goods Sold', slug: 'cost-of-goods-sold', @@ -358,4 +389,7 @@ export default [ }, UnearnedRevenueAccount, PrepardExpenses, + DiscountExpenseAccount, + PurchaseDiscountAccount, + OtherChargesAccount, ]; diff --git a/packages/server/src/interfaces/Bill.ts b/packages/server/src/interfaces/Bill.ts index a130a62ba..6e5391ee8 100644 --- a/packages/server/src/interfaces/Bill.ts +++ b/packages/server/src/interfaces/Bill.ts @@ -3,6 +3,7 @@ import { IDynamicListFilterDTO } from './DynamicFilter'; import { IItemEntry, IItemEntryDTO } from './ItemEntry'; import { IBillLandedCost } from './LandedCost'; import { AttachmentLinkDTO } from './Attachments'; +import { DiscountType } from './SaleInvoice'; export interface IBillDTO { vendorId: number; @@ -22,6 +23,13 @@ export interface IBillDTO { projectId?: number; isInclusiveTax?: boolean; attachments?: AttachmentLinkDTO[]; + + // # Discount + discount?: number; + discountType?: DiscountType; + + // # Adjustment + adjustment?: number; } export interface IBillEditDTO { diff --git a/packages/server/src/interfaces/CreditNote.ts b/packages/server/src/interfaces/CreditNote.ts index 035778273..4f0944aaa 100644 --- a/packages/server/src/interfaces/CreditNote.ts +++ b/packages/server/src/interfaces/CreditNote.ts @@ -1,5 +1,5 @@ import { Knex } from 'knex'; -import { IDynamicListFilter, IItemEntry } from '@/interfaces'; +import { DiscountType, IDynamicListFilter, IItemEntry } from '@/interfaces'; import { ILedgerEntry } from './Ledger'; import { AttachmentLinkDTO } from './Attachments'; @@ -23,6 +23,9 @@ export interface ICreditNoteNewDTO { branchId?: number; warehouseId?: number; attachments?: AttachmentLinkDTO[]; + discount?: number; + discountType?: DiscountType; + adjustment?: number; } export interface ICreditNoteEditDTO { diff --git a/packages/server/src/interfaces/ItemEntry.ts b/packages/server/src/interfaces/ItemEntry.ts index 210eadf40..a2693ab5c 100644 --- a/packages/server/src/interfaces/ItemEntry.ts +++ b/packages/server/src/interfaces/ItemEntry.ts @@ -13,14 +13,17 @@ export interface IItemEntry { itemId: number; description: string; + discountType?: string; discount: number; quantity: number; rate: number; amount: number; total: number; - amountInclusingTax: number; - amountExludingTax: number; + totalExcludingTax?: number; + + subtotalInclusingTax: number; + subtotalExcludingTax: number; discountAmount: number; landedCost: number; diff --git a/packages/server/src/interfaces/PaymentReceive.ts b/packages/server/src/interfaces/PaymentReceive.ts index ccba86b59..695a3b17a 100644 --- a/packages/server/src/interfaces/PaymentReceive.ts +++ b/packages/server/src/interfaces/PaymentReceive.ts @@ -177,7 +177,9 @@ export type IPaymentReceiveGLCommonEntry = Pick< | 'branchId' >; -export interface PaymentReceiveMailOpts extends CommonMailOptions {} +export interface PaymentReceiveMailOpts extends CommonMailOptions { + attachPdf?: boolean; +} export interface PaymentReceiveMailOptsDTO extends CommonMailOptionsDTO {} @@ -239,7 +241,6 @@ export interface PaymentReceivedPdfTemplateAttributes { paymentReceivedDateLabel: string; } - export interface IPaymentReceivedState { defaultTemplateId: number; -} \ No newline at end of file +} diff --git a/packages/server/src/interfaces/SaleEstimate.ts b/packages/server/src/interfaces/SaleEstimate.ts index 72dd4cd09..88ffcf51c 100644 --- a/packages/server/src/interfaces/SaleEstimate.ts +++ b/packages/server/src/interfaces/SaleEstimate.ts @@ -3,6 +3,7 @@ import { IItemEntry, IItemEntryDTO } from './ItemEntry'; import { IDynamicListFilterDTO } from '@/interfaces/DynamicFilter'; import { CommonMailOptions, CommonMailOptionsDTO } from './Mailable'; import { AttachmentLinkDTO } from './Attachments'; +import { DiscountType } from './SaleInvoice'; export interface ISaleEstimate { id?: number; @@ -24,6 +25,14 @@ export interface ISaleEstimate { branchId?: number; warehouseId?: number; + + total?: number; + totalLocal?: number; + + discountAmount?: number; + discountPercentage?: number | null; + + adjustment?: number; } export interface ISaleEstimateDTO { customerId: number; @@ -40,6 +49,13 @@ export interface ISaleEstimateDTO { branchId?: number; warehouseId?: number; attachments?: AttachmentLinkDTO[]; + + // # Discount + discount?: number; + discountType?: DiscountType; + + // # Adjustment + adjustment?: number; } export interface ISalesEstimatesFilter extends IDynamicListFilterDTO { diff --git a/packages/server/src/interfaces/SaleInvoice.ts b/packages/server/src/interfaces/SaleInvoice.ts index c3bdcde33..b7f208ac0 100644 --- a/packages/server/src/interfaces/SaleInvoice.ts +++ b/packages/server/src/interfaces/SaleInvoice.ts @@ -80,6 +80,18 @@ export interface ISaleInvoice { pdfTemplateId?: number; paymentMethods?: Array; + + adjustment?: number; + adjustmentLocal?: number | null; + + discount?: number; + discountAmount?: number; + discountAmountLocal?: number | null; +} + +export enum DiscountType { + Percentage = 'percentage', + Amount = 'amount', } export interface ISaleInvoiceDTO { @@ -102,6 +114,13 @@ export interface ISaleInvoiceDTO { isInclusiveTax?: boolean; attachments?: AttachmentLinkDTO[]; + + // # Discount + discount?: number; + discountType?: DiscountType; + + // # Adjustments + adjustments?: string; } export interface ISaleInvoiceCreateDTO extends ISaleInvoiceDTO { diff --git a/packages/server/src/interfaces/SaleReceipt.ts b/packages/server/src/interfaces/SaleReceipt.ts index f44e995d3..8d854d46b 100644 --- a/packages/server/src/interfaces/SaleReceipt.ts +++ b/packages/server/src/interfaces/SaleReceipt.ts @@ -2,6 +2,7 @@ import { Knex } from 'knex'; import { IItemEntry } from './ItemEntry'; import { CommonMailOptions, CommonMailOptionsDTO } from './Mailable'; import { AttachmentLinkDTO } from './Attachments'; +import { DiscountType } from './SaleInvoice'; export interface ISaleReceipt { id?: number; @@ -27,6 +28,20 @@ export interface ISaleReceipt { localAmount?: number; entries?: IItemEntry[]; + + subtotal?: number; + subtotalLocal?: number; + + total?: number; + totalLocal?: number; + + discountAmount: number; + discountPercentage?: number | null; + + adjustment?: number; + adjustmentLocal?: number | null; + + discountAmountLocal?: number | null; } export interface ISalesReceiptsFilter { @@ -47,6 +62,11 @@ export interface ISaleReceiptDTO { entries: any[]; branchId?: number; attachments?: AttachmentLinkDTO[]; + + discount?: number; + discountType?: DiscountType; + + adjustment?: number; } export interface ISalesReceiptsService { diff --git a/packages/server/src/interfaces/VendorCredit.ts b/packages/server/src/interfaces/VendorCredit.ts index 879b6b783..29254337f 100644 --- a/packages/server/src/interfaces/VendorCredit.ts +++ b/packages/server/src/interfaces/VendorCredit.ts @@ -1,4 +1,4 @@ -import { IDynamicListFilter, IItemEntry, IItemEntryDTO } from '@/interfaces'; +import { DiscountType, IDynamicListFilter, IItemEntry, IItemEntryDTO } from '@/interfaces'; import { Knex } from 'knex'; import { AttachmentLinkDTO } from './Attachments'; @@ -63,6 +63,11 @@ export interface IVendorCreditDTO { branchId?: number; warehouseId?: number; attachments?: AttachmentLinkDTO[]; + + discount?: number; + discountType?: DiscountType; + + adjustment?: number; } export interface IVendorCreditCreateDTO extends IVendorCreditDTO {} diff --git a/packages/server/src/models/Bill.ts b/packages/server/src/models/Bill.ts index f4e313e72..e813ac52e 100644 --- a/packages/server/src/models/Bill.ts +++ b/packages/server/src/models/Bill.ts @@ -1,12 +1,14 @@ import { Model, raw, mixin } from 'objection'; -import { castArray, difference } from 'lodash'; +import { castArray, defaultTo, difference } from 'lodash'; import moment from 'moment'; +import * as R from 'ramda'; import TenantModel from 'models/TenantModel'; import BillSettings from './Bill.Settings'; import ModelSetting from './ModelSetting'; import CustomViewBaseModel from './CustomViewBaseModel'; import { DEFAULT_VIEWS } from '@/services/Purchases/Bills/constants'; import ModelSearchable from './ModelSearchable'; +import { DiscountType } from '@/interfaces'; export default class Bill extends mixin(TenantModel, [ ModelSetting, @@ -21,6 +23,11 @@ export default class Bill extends mixin(TenantModel, [ public taxAmountWithheld: number; public exchangeRate: number; + public discount: number; + public discountType: DiscountType; + + public adjustment: number; + /** * Timestamps columns. */ @@ -47,6 +54,13 @@ export default class Bill extends mixin(TenantModel, [ 'localAllocatedCostAmount', 'billableAmount', 'amountLocal', + + 'discountAmount', + 'discountAmountLocal', + 'discountPercentage', + + 'adjustmentLocal', + 'subtotal', 'subtotalLocal', 'subtotalExludingTax', @@ -98,14 +112,53 @@ export default class Bill extends mixin(TenantModel, [ return this.taxAmountWithheld * this.exchangeRate; } + /** + * Discount amount. + * @returns {number} + */ + get discountAmount() { + return this.discountType === DiscountType.Amount + ? this.discount + : this.subtotal * (this.discount / 100); + } + + /** + * Discount amount in local currency. + * @returns {number | null} + */ + get discountAmountLocal() { + return this.discountAmount ? this.discountAmount * this.exchangeRate : null; + } + + /** + /** + * Discount percentage. + * @returns {number | null} + */ + get discountPercentage(): number | null { + return this.discountType === DiscountType.Percentage ? this.discount : null; + } + + /** + * Adjustment amount in local currency. + * @returns {number | null} + */ + get adjustmentLocal() { + return this.adjustment ? this.adjustment * this.exchangeRate : null; + } + /** * Invoice total. (Tax included) * @returns {number} */ get total() { - return this.isInclusiveTax - ? this.subtotal - : this.subtotal + this.taxAmountWithheld; + const adjustmentAmount = defaultTo(this.adjustment, 0); + + return R.compose( + R.add(adjustmentAmount), + R.subtract(R.__, this.discountAmount), + R.when(R.always(this.isInclusiveTax), R.add(this.taxAmountWithheld)) + )(this.subtotal); } /** diff --git a/packages/server/src/models/CreditNote.ts b/packages/server/src/models/CreditNote.ts index b7400b14e..ab5f6c878 100644 --- a/packages/server/src/models/CreditNote.ts +++ b/packages/server/src/models/CreditNote.ts @@ -5,12 +5,20 @@ import CustomViewBaseModel from './CustomViewBaseModel'; import { DEFAULT_VIEWS } from '@/services/CreditNotes/constants'; import ModelSearchable from './ModelSearchable'; import CreditNoteMeta from './CreditNote.Meta'; +import { DiscountType } from '@/interfaces'; export default class CreditNote extends mixin(TenantModel, [ ModelSetting, CustomViewBaseModel, ModelSearchable, ]) { + public amount: number; + public exchangeRate: number; + public openedAt: Date; + public discount: number; + public discountType: DiscountType; + public adjustment: number; + /** * Table name */ @@ -35,8 +43,21 @@ export default class CreditNote extends mixin(TenantModel, [ 'isPublished', 'isOpen', 'isClosed', + 'creditsRemaining', 'creditsUsed', + + 'subtotal', + 'subtotalLocal', + + 'discountAmount', + 'discountAmountLocal', + 'discountPercentage', + + 'total', + 'totalLocal', + + 'adjustmentLocal', ]; } @@ -48,6 +69,72 @@ export default class CreditNote extends mixin(TenantModel, [ return this.amount * this.exchangeRate; } + /** + * Credit note subtotal. + * @returns {number} + */ + get subtotal() { + return this.amount; + } + + /** + * Credit note subtotal in local currency. + * @returns {number} + */ + get subtotalLocal() { + return this.subtotal * this.exchangeRate; + } + + /** + * Discount amount. + * @returns {number} + */ + get discountAmount() { + return this.discountType === DiscountType.Amount + ? this.discount + : this.subtotal * (this.discount / 100); + } + + /** + * Discount amount in local currency. + * @returns {number} + */ + get discountAmountLocal() { + return this.discountAmount ? this.discountAmount * this.exchangeRate : null; + } + + /** + * Discount percentage. + * @returns {number | null} + */ + get discountPercentage(): number | null { + return this.discountType === DiscountType.Percentage ? this.discount : null; + } + + /** + * Adjustment amount in local currency. + * @returns {number} + */ + get adjustmentLocal() { + return this.adjustment ? this.adjustment * this.exchangeRate : null; + } + + /** + * Credit note total. + * @returns {number} + */ + get total() { + return this.subtotal - this.discountAmount + this.adjustment; + } + + /** + * Credit note total in local currency. + * @returns {number} + */ + get totalLocal() { + return this.total * this.exchangeRate; + } + /** * Detarmines whether the credit note is draft. * @returns {boolean} @@ -176,6 +263,7 @@ export default class CreditNote extends mixin(TenantModel, [ const Branch = require('models/Branch'); const Document = require('models/Document'); const Warehouse = require('models/Warehouse'); + const { PdfTemplate } = require('models/PdfTemplate'); return { /** @@ -266,6 +354,18 @@ export default class CreditNote extends mixin(TenantModel, [ query.where('model_ref', 'CreditNote'); }, }, + + /** + * Credit note may belongs to pdf branding template. + */ + pdfTemplate: { + relation: Model.BelongsToOneRelation, + modelClass: PdfTemplate, + join: { + from: 'credit_notes.pdfTemplateId', + to: 'pdf_templates.id', + }, + }, }; } diff --git a/packages/server/src/models/ItemEntry.ts b/packages/server/src/models/ItemEntry.ts index 0a2aebda0..3152779ce 100644 --- a/packages/server/src/models/ItemEntry.ts +++ b/packages/server/src/models/ItemEntry.ts @@ -1,6 +1,12 @@ import { Model } from 'objection'; import TenantModel from 'models/TenantModel'; import { getExlusiveTaxAmount, getInclusiveTaxAmount } from '@/utils/taxRate'; +import { DiscountType } from '@/interfaces'; + +// Subtotal (qty * rate) (tax inclusive) +// Subtotal Tax Exclusive (Subtotal - Tax Amount) +// Discount (Is percentage ? amount * discount : discount) +// Total (Subtotal - Discount) export default class ItemEntry extends TenantModel { public taxRate: number; @@ -8,7 +14,7 @@ export default class ItemEntry extends TenantModel { public quantity: number; public rate: number; public isInclusiveTax: number; - + public discountType: DiscountType; /** * Table name. * @returns {string} @@ -31,10 +37,24 @@ export default class ItemEntry extends TenantModel { */ static get virtualAttributes() { return [ + // Amount (qty * rate) 'amount', + 'taxAmount', - 'amountExludingTax', - 'amountInclusingTax', + + // Subtotal (qty * rate) + (tax inclusive) + 'subtotalInclusingTax', + + // Subtotal Tax Exclusive (Subtotal - Tax Amount) + 'subtotalExcludingTax', + + // Subtotal (qty * rate) + (tax inclusive) + 'subtotal', + + // Discount (Is percentage ? amount * discount : discount) + 'discountAmount', + + // Total (Subtotal - Discount) 'total', ]; } @@ -45,7 +65,15 @@ export default class ItemEntry extends TenantModel { * @returns {number} */ get total() { - return this.amountInclusingTax; + return this.subtotal - this.discountAmount; + } + + /** + * Total (excluding tax). + * @returns {number} + */ + get totalExcludingTax() { + return this.subtotalExcludingTax - this.discountAmount; } /** @@ -57,19 +85,27 @@ export default class ItemEntry extends TenantModel { return this.quantity * this.rate; } + /** + * Subtotal amount (tax inclusive). + * @returns {number} + */ + get subtotal() { + return this.subtotalInclusingTax; + } + /** * Item entry amount including tax. * @returns {number} */ - get amountInclusingTax() { + get subtotalInclusingTax() { return this.isInclusiveTax ? this.amount : this.amount + this.taxAmount; } /** - * Item entry amount excluding tax. + * Subtotal amount (tax exclusive). * @returns {number} */ - get amountExludingTax() { + get subtotalExcludingTax() { return this.isInclusiveTax ? this.amount - this.taxAmount : this.amount; } @@ -78,7 +114,9 @@ export default class ItemEntry extends TenantModel { * @returns {number} */ get discountAmount() { - return this.amount * (this.discount / 100); + return this.discountType === DiscountType.Percentage + ? this.amount * (this.discount / 100) + : this.discount; } /** diff --git a/packages/server/src/models/PaymentReceive.ts b/packages/server/src/models/PaymentReceive.ts index 0fc013962..cd7df23a6 100644 --- a/packages/server/src/models/PaymentReceive.ts +++ b/packages/server/src/models/PaymentReceive.ts @@ -11,6 +11,10 @@ export default class PaymentReceive extends mixin(TenantModel, [ CustomViewBaseModel, ModelSearchable, ]) { + amount!: number; + paymentAmount!: number; + exchangeRate!: number; + /** * Table name. */ @@ -29,7 +33,7 @@ export default class PaymentReceive extends mixin(TenantModel, [ * Virtual attributes. */ static get virtualAttributes() { - return ['localAmount']; + return ['localAmount', 'total']; } /** @@ -40,6 +44,14 @@ export default class PaymentReceive extends mixin(TenantModel, [ return this.amount * this.exchangeRate; } + /** + * Payment receive total. + * @returns {number} + */ + get total() { + return this.paymentAmount; + } + /** * Resourcable model. */ @@ -57,6 +69,7 @@ export default class PaymentReceive extends mixin(TenantModel, [ const Account = require('models/Account'); const Branch = require('models/Branch'); const Document = require('models/Document'); + const { PdfTemplate } = require('models/PdfTemplate'); return { customer: { @@ -131,6 +144,18 @@ export default class PaymentReceive extends mixin(TenantModel, [ query.where('model_ref', 'PaymentReceive'); }, }, + + /** + * Payment received may belongs to pdf branding template. + */ + pdfTemplate: { + relation: Model.BelongsToOneRelation, + modelClass: PdfTemplate, + join: { + from: 'payment_receives.pdfTemplateId', + to: 'pdf_templates.id', + }, + }, }; } diff --git a/packages/server/src/models/SaleEstimate.ts b/packages/server/src/models/SaleEstimate.ts index 47ecebce5..fce0f334b 100644 --- a/packages/server/src/models/SaleEstimate.ts +++ b/packages/server/src/models/SaleEstimate.ts @@ -7,12 +7,30 @@ import ModelSetting from './ModelSetting'; import CustomViewBaseModel from './CustomViewBaseModel'; import { DEFAULT_VIEWS } from '@/services/Sales/Estimates/constants'; import ModelSearchable from './ModelSearchable'; +import { DiscountType } from '@/interfaces'; +import { defaultTo } from 'lodash'; export default class SaleEstimate extends mixin(TenantModel, [ ModelSetting, CustomViewBaseModel, ModelSearchable, ]) { + public amount: number; + public exchangeRate: number; + + public discount: number; + public discountType: DiscountType; + + public adjustment: number; + + public expirationDate!: string; + public deliveredAt!: string | null; + public approvedAt!: string | null; + public rejectedAt!: string | null; + + public convertedToInvoiceId!: number | null; + public convertedToInvoiceAt!: string | null; + /** * Table name */ @@ -33,6 +51,12 @@ export default class SaleEstimate extends mixin(TenantModel, [ static get virtualAttributes() { return [ 'localAmount', + 'discountAmount', + 'discountPercentage', + 'total', + 'totalLocal', + 'subtotal', + 'subtotalLocal', 'isDelivered', 'isExpired', 'isConvertedToInvoice', @@ -49,6 +73,60 @@ export default class SaleEstimate extends mixin(TenantModel, [ return this.amount * this.exchangeRate; } + /** + * Estimate subtotal. + * @returns {number} + */ + get subtotal() { + return this.amount;; + } + + /** + * Estimate subtotal in local currency. + * @returns {number} + */ + get subtotalLocal() { + return this.localAmount; + } + + /** + * Discount amount. + * @returns {number} + */ + get discountAmount() { + return this.discountType === DiscountType.Amount + ? this.discount + : this.subtotal * (this.discount / 100); + } + + /** + * Discount percentage. + * @returns {number | null} + */ + get discountPercentage(): number | null { + return this.discountType === DiscountType.Percentage + ? this.discount + : null; + } + + /** + * Estimate total. + * @returns {number} + */ + get total() { + const adjustmentAmount = defaultTo(this.adjustment, 0); + + return this.subtotal - this.discountAmount + adjustmentAmount; + } + + /** + * Estimate total in local currency. + * @returns {number} + */ + get totalLocal() { + return this.total * this.exchangeRate; + } + /** * Detarmines whether the sale estimate converted to sale invoice. * @return {boolean} @@ -184,6 +262,7 @@ export default class SaleEstimate extends mixin(TenantModel, [ const Branch = require('models/Branch'); const Warehouse = require('models/Warehouse'); const Document = require('models/Document'); + const { PdfTemplate } = require('models/PdfTemplate'); return { customer: { @@ -252,6 +331,18 @@ export default class SaleEstimate extends mixin(TenantModel, [ query.where('model_ref', 'SaleEstimate'); }, }, + + /** + * Sale estimate may belongs to pdf branding template. + */ + pdfTemplate: { + relation: Model.BelongsToOneRelation, + modelClass: PdfTemplate, + join: { + from: 'sales_estimates.pdfTemplateId', + to: 'pdf_templates.id', + }, + }, }; } diff --git a/packages/server/src/models/SaleInvoice.ts b/packages/server/src/models/SaleInvoice.ts index c75e4884a..145f7beeb 100644 --- a/packages/server/src/models/SaleInvoice.ts +++ b/packages/server/src/models/SaleInvoice.ts @@ -1,5 +1,6 @@ import { mixin, Model, raw } from 'objection'; -import { castArray, takeWhile } from 'lodash'; +import * as R from 'ramda'; +import { castArray, defaultTo, takeWhile } from 'lodash'; import moment from 'moment'; import TenantModel from 'models/TenantModel'; import ModelSetting from './ModelSetting'; @@ -7,6 +8,7 @@ import SaleInvoiceMeta from './SaleInvoice.Settings'; import CustomViewBaseModel from './CustomViewBaseModel'; import { DEFAULT_VIEWS } from '@/services/Sales/Invoices/constants'; import ModelSearchable from './ModelSearchable'; +import { DiscountType } from '@/interfaces'; export default class SaleInvoice extends mixin(TenantModel, [ ModelSetting, @@ -24,6 +26,9 @@ export default class SaleInvoice extends mixin(TenantModel, [ public dueDate: Date; public deliveredAt: Date; public pdfTemplateId: number; + public discount: number; + public discountType: DiscountType; + public adjustment: number | null; /** * Table name @@ -68,10 +73,15 @@ export default class SaleInvoice extends mixin(TenantModel, [ 'subtotalExludingTax', 'taxAmountWithheldLocal', + 'discountAmount', + 'discountAmountLocal', + 'discountPercentage', + 'total', 'totalLocal', 'writtenoffAmountLocal', + 'adjustmentLocal', ]; } @@ -126,14 +136,52 @@ export default class SaleInvoice extends mixin(TenantModel, [ return this.taxAmountWithheld * this.exchangeRate; } + /** + * Discount amount. + * @returns {number} + */ + get discountAmount() { + return this.discountType === DiscountType.Amount + ? this.discount + : this.subtotal * (this.discount / 100); + } + + /** + * Local discount amount. + * @returns {number | null} + */ + get discountAmountLocal() { + return this.discountAmount ? this.discountAmount * this.exchangeRate : null; + } + + /** + * Discount percentage. + * @returns {number | null} + */ + get discountPercentage(): number | null { + return this.discountType === DiscountType.Percentage ? this.discount : null; + } + + /** + * Adjustment amount in local currency. + * @returns {number | null} + */ + get adjustmentLocal(): number | null { + return this.adjustment ? this.adjustment * this.exchangeRate : null; + } + /** * Invoice total. (Tax included) * @returns {number} */ get total() { - return this.isInclusiveTax - ? this.subtotal - : this.subtotal + this.taxAmountWithheld; + const adjustmentAmount = defaultTo(this.adjustment, 0); + + return R.compose( + R.add(adjustmentAmount), + R.subtract(R.__, this.discountAmount), + R.when(R.always(this.isInclusiveTax), R.add(this.taxAmountWithheld)) + )(this.subtotal); } /** @@ -605,7 +653,7 @@ export default class SaleInvoice extends mixin(TenantModel, [ join: { from: 'sales_invoices.pdfTemplateId', to: 'pdf_templates.id', - } + }, }, }; } diff --git a/packages/server/src/models/SaleReceipt.ts b/packages/server/src/models/SaleReceipt.ts index 9ac76640e..95abf7430 100644 --- a/packages/server/src/models/SaleReceipt.ts +++ b/packages/server/src/models/SaleReceipt.ts @@ -5,12 +5,23 @@ import SaleReceiptSettings from './SaleReceipt.Settings'; import CustomViewBaseModel from './CustomViewBaseModel'; import { DEFAULT_VIEWS } from '@/services/Sales/Receipts/constants'; import ModelSearchable from './ModelSearchable'; +import { DiscountType } from '@/interfaces'; +import { defaultTo } from 'lodash'; export default class SaleReceipt extends mixin(TenantModel, [ ModelSetting, CustomViewBaseModel, ModelSearchable, ]) { + public amount: number; + public exchangeRate: number; + public closedAt: Date; + + public discount: number; + public discountType: DiscountType; + + public adjustment: number; + /** * Table name */ @@ -29,7 +40,28 @@ export default class SaleReceipt extends mixin(TenantModel, [ * Virtual attributes. */ static get virtualAttributes() { - return ['localAmount', 'isClosed', 'isDraft']; + return [ + 'localAmount', + + 'subtotal', + 'subtotalLocal', + + 'total', + 'totalLocal', + + 'adjustment', + 'adjustmentLocal', + + 'discountAmount', + 'discountAmountLocal', + 'discountPercentage', + + 'paid', + 'paidLocal', + + 'isClosed', + 'isDraft', + ]; } /** @@ -40,6 +72,90 @@ export default class SaleReceipt extends mixin(TenantModel, [ return this.amount * this.exchangeRate; } + /** + * Receipt subtotal. + * @returns {number} + */ + get subtotal() { + return this.amount; + } + + /** + * Receipt subtotal in local currency. + * @returns {number} + */ + get subtotalLocal() { + return this.localAmount; + } + + /** + * Discount amount. + * @returns {number} + */ + get discountAmount() { + return this.discountType === DiscountType.Amount + ? this.discount + : this.subtotal * (this.discount / 100); + } + + /** + * Discount amount in local currency. + * @returns {number | null} + */ + get discountAmountLocal() { + return this.discountAmount ? this.discountAmount * this.exchangeRate : null; + } + + /** + * Discount percentage. + * @returns {number | null} + */ + get discountPercentage(): number | null { + return this.discountType === DiscountType.Percentage ? this.discount : null; + } + + /** + * Receipt total. + * @returns {number} + */ + get total() { + const adjustmentAmount = defaultTo(this.adjustment, 0); + + return this.subtotal - this.discountAmount + adjustmentAmount; + } + + /** + * Receipt total in local currency. + * @returns {number} + */ + get totalLocal() { + return this.total * this.exchangeRate; + } + + /** + * Adjustment amount in local currency. + * @returns {number} + */ + get adjustmentLocal() { + return this.adjustment * this.exchangeRate; + } + + /** + * Receipt paid amount. + * @returns {number} + */ + get paid() { + return this.total; + } + + /** + * Receipt paid amount in local currency. + * @returns {number} + */ + get paidLocal() { + return this.paid * this.exchangeRate; + } + /** * Detarmine whether the sale receipt closed. * @return {boolean} diff --git a/packages/server/src/models/VendorCredit.ts b/packages/server/src/models/VendorCredit.ts index c7de45e54..75473060e 100644 --- a/packages/server/src/models/VendorCredit.ts +++ b/packages/server/src/models/VendorCredit.ts @@ -6,26 +6,26 @@ import CustomViewBaseModel from './CustomViewBaseModel'; import { DEFAULT_VIEWS } from '@/services/Purchases/VendorCredits/constants'; import ModelSearchable from './ModelSearchable'; import VendorCreditMeta from './VendorCredit.Meta'; +import { DiscountType } from '@/interfaces'; export default class VendorCredit extends mixin(TenantModel, [ ModelSetting, CustomViewBaseModel, ModelSearchable, ]) { + public amount: number; + public exchangeRate: number; + public openedAt: Date; + public discount: number; + public discountType: DiscountType; + public adjustment: number; + /** * Table name */ static get tableName() { return 'vendor_credits'; } - - /** - * Virtual attributes. - */ - static get virtualAttributes() { - return ['localAmount']; - } - /** * Vendor credit amount in local currency. * @returns {number} @@ -34,6 +34,72 @@ export default class VendorCredit extends mixin(TenantModel, [ return this.amount * this.exchangeRate; } + /** + * Vendor credit subtotal. + * @returns {number} + */ + get subtotal() { + return this.amount; + } + + /** + * Vendor credit subtotal in local currency. + * @returns {number} + */ + get subtotalLocal() { + return this.subtotal * this.exchangeRate; + } + + /** + * Discount amount. + * @returns {number} + */ + get discountAmount() { + return this.discountType === DiscountType.Amount + ? this.discount + : this.subtotal * (this.discount / 100); + } + + /** + * Discount amount in local currency. + * @returns {number | null} + */ + get discountAmountLocal() { + return this.discountAmount ? this.discountAmount * this.exchangeRate : null; + } + + /** + * Discount percentage. + * @returns {number | null} + */ + get discountPercentage(): number | null { + return this.discountType === DiscountType.Percentage ? this.discount : null; + } + + /** + * Adjustment amount in local currency. + * @returns {number | null} + */ + get adjustmentLocal() { + return this.adjustment ? this.adjustment * this.exchangeRate : null; + } + + /** + * Vendor credit total. + * @returns {number} + */ + get total() { + return this.subtotal - this.discountAmount + this.adjustment; + } + + /** + * Vendor credit total in local currency. + * @returns {number} + */ + get totalLocal() { + return this.total * this.exchangeRate; + } + /** * Model modifiers. */ @@ -120,7 +186,24 @@ export default class VendorCredit extends mixin(TenantModel, [ * Virtual attributes. */ static get virtualAttributes() { - return ['isDraft', 'isPublished', 'isOpen', 'isClosed', 'creditsRemaining']; + return [ + 'isDraft', + 'isPublished', + 'isOpen', + 'isClosed', + + 'creditsRemaining', + 'localAmount', + + 'discountAmount', + 'discountAmountLocal', + 'discountPercentage', + + 'adjustmentLocal', + + 'total', + 'totalLocal', + ]; } /** diff --git a/packages/server/src/repositories/AccountRepository.ts b/packages/server/src/repositories/AccountRepository.ts index 53b26e284..43e320b22 100644 --- a/packages/server/src/repositories/AccountRepository.ts +++ b/packages/server/src/repositories/AccountRepository.ts @@ -3,7 +3,11 @@ import TenantRepository from '@/repositories/TenantRepository'; import { IAccount } from '@/interfaces'; import { Knex } from 'knex'; import { + DiscountExpenseAccount, + OtherChargesAccount, + OtherExpensesAccount, PrepardExpenses, + PurchaseDiscountAccount, StripeClearingAccount, TaxPayableAccount, UnearnedRevenueAccount, @@ -188,9 +192,9 @@ export default class AccountRepository extends TenantRepository { /** * Finds or creates the unearned revenue. - * @param {Record} extraAttrs - * @param {Knex.Transaction} trx - * @returns + * @param {Record} extraAttrs + * @param {Knex.Transaction} trx + * @returns */ public async findOrCreateUnearnedRevenue( extraAttrs: Record = {}, @@ -219,9 +223,9 @@ export default class AccountRepository extends TenantRepository { /** * Finds or creates the prepard expenses account. - * @param {Record} extraAttrs - * @param {Knex.Transaction} trx - * @returns + * @param {Record} extraAttrs + * @param {Knex.Transaction} trx + * @returns */ public async findOrCreatePrepardExpenses( extraAttrs: Record = {}, @@ -249,12 +253,11 @@ export default class AccountRepository extends TenantRepository { return result; } - /** * Finds or creates the stripe clearing account. - * @param {Record} extraAttrs - * @param {Knex.Transaction} trx - * @returns + * @param {Record} extraAttrs + * @param {Knex.Transaction} trx + * @returns */ public async findOrCreateStripeClearing( extraAttrs: Record = {}, @@ -281,4 +284,114 @@ export default class AccountRepository extends TenantRepository { } return result; } + + /** + * Finds or creates the discount expense account. + * @param {Record} extraAttrs + * @param {Knex.Transaction} trx + * @returns + */ + public async findOrCreateDiscountAccount( + extraAttrs: Record = {}, + trx?: Knex.Transaction + ) { + // Retrieves the given tenant metadata. + const tenantMeta = await TenantMetadata.query().findOne({ + tenantId: this.tenantId, + }); + const _extraAttrs = { + currencyCode: tenantMeta.baseCurrency, + ...extraAttrs, + }; + + let result = await this.model + .query(trx) + .findOne({ slug: DiscountExpenseAccount.slug, ..._extraAttrs }); + + if (!result) { + result = await this.model.query(trx).insertAndFetch({ + ...DiscountExpenseAccount, + ..._extraAttrs, + }); + } + return result; + } + + public async findOrCreatePurchaseDiscountAccount( + extraAttrs: Record = {}, + trx?: Knex.Transaction + ) { + // Retrieves the given tenant metadata. + const tenantMeta = await TenantMetadata.query().findOne({ + tenantId: this.tenantId, + }); + const _extraAttrs = { + currencyCode: tenantMeta.baseCurrency, + ...extraAttrs, + }; + + let result = await this.model + .query(trx) + .findOne({ slug: PurchaseDiscountAccount.slug, ..._extraAttrs }); + + if (!result) { + result = await this.model.query(trx).insertAndFetch({ + ...PurchaseDiscountAccount, + ..._extraAttrs, + }); + } + return result; + } + + public async findOrCreateOtherChargesAccount( + extraAttrs: Record = {}, + trx?: Knex.Transaction + ) { + // Retrieves the given tenant metadata. + const tenantMeta = await TenantMetadata.query().findOne({ + tenantId: this.tenantId, + }); + const _extraAttrs = { + currencyCode: tenantMeta.baseCurrency, + ...extraAttrs, + }; + + let result = await this.model + .query(trx) + .findOne({ slug: OtherChargesAccount.slug, ..._extraAttrs }); + + if (!result) { + result = await this.model.query(trx).insertAndFetch({ + ...OtherChargesAccount, + ..._extraAttrs, + }); + } + return result; + } + + public async findOrCreateOtherExpensesAccount( + extraAttrs: Record = {}, + trx?: Knex.Transaction + ) { + // Retrieves the given tenant metadata. + const tenantMeta = await TenantMetadata.query().findOne({ + tenantId: this.tenantId, + }); + const _extraAttrs = { + currencyCode: tenantMeta.baseCurrency, + ...extraAttrs, + }; + + let result = await this.model + .query(trx) + .findOne({ slug: OtherExpensesAccount.slug, ..._extraAttrs }); + + if (!result) { + result = await this.model.query(trx).insertAndFetch({ + ...OtherExpensesAccount, + ..._extraAttrs, + }); + } + return result; + } } diff --git a/packages/server/src/services/Accounting/Ledger.ts b/packages/server/src/services/Accounting/Ledger.ts index 155a61e10..6e7519bc8 100644 --- a/packages/server/src/services/Accounting/Ledger.ts +++ b/packages/server/src/services/Accounting/Ledger.ts @@ -238,6 +238,7 @@ export default class Ledger implements ILedger { return { credit: defaultTo(entry.credit, 0), debit: defaultTo(entry.debit, 0), + exchangeRate: entry.exchangeRate, currencyCode: entry.currencyCode, diff --git a/packages/server/src/services/Accounting/LedgerEntriesStorage.ts b/packages/server/src/services/Accounting/LedgerEntriesStorage.ts index dd192b811..8f789e3cf 100644 --- a/packages/server/src/services/Accounting/LedgerEntriesStorage.ts +++ b/packages/server/src/services/Accounting/LedgerEntriesStorage.ts @@ -9,15 +9,18 @@ import { import HasTenancyService from '@/services/Tenancy/TenancyService'; import { transformLedgerEntryToTransaction } from './utils'; +// Filter the blank entries. +const filterBlankEntry = (entry: ILedgerEntry) => Boolean(entry.credit || entry.debit); + @Service() export class LedgerEntriesStorage { @Inject() - tenancy: HasTenancyService; + private tenancy: HasTenancyService; /** * Saves entries of the given ledger. - * @param {number} tenantId - * @param {ILedger} ledger - * @param {Knex.Transaction} knex + * @param {number} tenantId + * @param {ILedger} ledger + * @param {Knex.Transaction} knex * @returns {Promise} */ public saveEntries = async ( @@ -26,7 +29,7 @@ export class LedgerEntriesStorage { trx?: Knex.Transaction ) => { const saveEntryQueue = async.queue(this.saveEntryTask, 10); - const entries = ledger.getEntries(); + const entries = ledger.filter(filterBlankEntry).getEntries(); entries.forEach((entry) => { saveEntryQueue.push({ tenantId, entry, trx }); @@ -57,8 +60,8 @@ export class LedgerEntriesStorage { /** * Saves the ledger entry to the account transactions repository. - * @param {number} tenantId - * @param {ILedgerEntry} entry + * @param {number} tenantId + * @param {ILedgerEntry} entry * @returns {Promise} */ private saveEntry = async ( diff --git a/packages/server/src/services/CreditNotes/CreditNoteGLEntries.ts b/packages/server/src/services/CreditNotes/CreditNoteGLEntries.ts index 697072f3c..78bf7884f 100644 --- a/packages/server/src/services/CreditNotes/CreditNoteGLEntries.ts +++ b/packages/server/src/services/CreditNotes/CreditNoteGLEntries.ts @@ -12,6 +12,7 @@ import { import HasTenancyService from '@/services/Tenancy/TenancyService'; import Ledger from '@/services/Accounting/Ledger'; import LedgerStorageService from '@/services/Accounting/LedgerStorageService'; +import { SaleReceipt } from '@/models'; @Service() export default class CreditNoteGLEntries { @@ -29,11 +30,15 @@ export default class CreditNoteGLEntries { */ private getCreditNoteGLedger = ( creditNote: ICreditNote, - receivableAccount: number + receivableAccount: number, + discountAccount: number, + adjustmentAccount: number ): Ledger => { const ledgerEntries = this.getCreditNoteGLEntries( creditNote, - receivableAccount + receivableAccount, + discountAccount, + adjustmentAccount ); return new Ledger(ledgerEntries); }; @@ -49,9 +54,16 @@ export default class CreditNoteGLEntries { tenantId: number, creditNote: ICreditNote, payableAccount: number, + discountAccount: number, + adjustmentAccount: number, trx?: Knex.Transaction ): Promise => { - const ledger = this.getCreditNoteGLedger(creditNote, payableAccount); + const ledger = this.getCreditNoteGLedger( + creditNote, + payableAccount, + discountAccount, + adjustmentAccount + ); await this.ledgerStorage.commit(tenantId, ledger, trx); }; @@ -98,11 +110,18 @@ export default class CreditNoteGLEntries { const ARAccount = await accountRepository.findOrCreateAccountReceivable( creditNoteWithItems.currencyCode ); + const discountAccount = await accountRepository.findOrCreateDiscountAccount( + {} + ); + const adjustmentAccount = + await accountRepository.findOrCreateOtherChargesAccount({}); // Saves the credit note GL entries. await this.saveCreditNoteGLEntries( tenantId, creditNoteWithItems, ARAccount.id, + discountAccount.id, + adjustmentAccount.id, trx ); }; @@ -169,7 +188,7 @@ export default class CreditNoteGLEntries { return { ...commonEntry, - credit: creditNote.localAmount, + credit: creditNote.totalLocal, accountId: ARAccountId, contactId: creditNote.customerId, index: 1, @@ -191,11 +210,11 @@ export default class CreditNoteGLEntries { index: number ): ILedgerEntry => { const commonEntry = this.getCreditNoteCommonEntry(creditNote); - const localAmount = entry.amount * creditNote.exchangeRate; + const totalLocal = entry.totalExcludingTax * creditNote.exchangeRate; return { ...commonEntry, - debit: localAmount, + debit: totalLocal, accountId: entry.sellAccountId || entry.item.sellAccountId, note: entry.description, index: index + 2, @@ -206,6 +225,50 @@ export default class CreditNoteGLEntries { } ); + /** + * Retrieves the credit note discount entry. + * @param {ICreditNote} creditNote + * @param {number} discountAccountId + * @returns {ILedgerEntry} + */ + private getDiscountEntry = ( + creditNote: ICreditNote, + discountAccountId: number + ): ILedgerEntry => { + const commonEntry = this.getCreditNoteCommonEntry(creditNote); + + return { + ...commonEntry, + credit: creditNote.discountAmountLocal, + accountId: discountAccountId, + index: 1, + accountNormal: AccountNormal.CREDIT, + }; + }; + + /** + * Retrieves the credit note adjustment entry. + * @param {ICreditNote} creditNote + * @param {number} adjustmentAccountId + * @returns {ILedgerEntry} + */ + private getAdjustmentEntry = ( + creditNote: ICreditNote, + adjustmentAccountId: number + ): ILedgerEntry => { + const commonEntry = this.getCreditNoteCommonEntry(creditNote); + const adjustmentAmount = Math.abs(creditNote.adjustmentLocal); + + return { + ...commonEntry, + credit: creditNote.adjustmentLocal < 0 ? adjustmentAmount : 0, + debit: creditNote.adjustmentLocal > 0 ? adjustmentAmount : 0, + accountId: adjustmentAccountId, + accountNormal: AccountNormal.CREDIT, + index: 1, + }; + }; + /** * Retrieve the credit note GL entries. * @param {ICreditNote} creditNote - Credit note. @@ -214,13 +277,21 @@ export default class CreditNoteGLEntries { */ public getCreditNoteGLEntries = ( creditNote: ICreditNote, - ARAccountId: number + ARAccountId: number, + discountAccountId: number, + adjustmentAccountId: number ): ILedgerEntry[] => { const AREntry = this.getCreditNoteAREntry(creditNote, ARAccountId); const getItemEntry = this.getCreditNoteItemEntry(creditNote); const itemsEntries = creditNote.entries.map(getItemEntry); - return [AREntry, ...itemsEntries]; + const discountEntry = this.getDiscountEntry(creditNote, discountAccountId); + const adjustmentEntry = this.getAdjustmentEntry( + creditNote, + adjustmentAccountId + ); + + return [AREntry, discountEntry, adjustmentEntry, ...itemsEntries]; }; } diff --git a/packages/server/src/services/CreditNotes/CreditNoteTransformer.ts b/packages/server/src/services/CreditNotes/CreditNoteTransformer.ts index d4780a754..86e84681d 100644 --- a/packages/server/src/services/CreditNotes/CreditNoteTransformer.ts +++ b/packages/server/src/services/CreditNotes/CreditNoteTransformer.ts @@ -18,6 +18,18 @@ export class CreditNoteTransformer extends Transformer { 'formattedAmount', 'formattedCreditsUsed', 'formattedSubtotal', + + 'discountAmountFormatted', + 'discountAmountLocalFormatted', + + 'discountPercentageFormatted', + + 'adjustmentFormatted', + 'adjustmentLocalFormatted', + + 'totalFormatted', + 'totalLocalFormatted', + 'entries', 'attachments', ]; @@ -34,7 +46,7 @@ export class CreditNoteTransformer extends Transformer { /** * Retrieve formatted created at date. - * @param credit + * @param credit * @returns {string} */ protected formattedCreatedAt = (credit): string => { @@ -83,6 +95,85 @@ export class CreditNoteTransformer extends Transformer { return formatNumber(credit.amount, { money: false }); }; + /** + * Retrieves formatted discount amount. + * @param credit + * @returns {string} + */ + protected discountAmountFormatted = (credit): string => { + return formatNumber(credit.discountAmount, { + currencyCode: credit.currencyCode, + excerptZero: true, + }); + }; + + /** + * Retrieves the formatted discount amount in local currency. + * @param {ICreditNote} credit + * @returns {string} + */ + protected discountAmountLocalFormatted = (credit): string => { + return formatNumber(credit.discountAmountLocal, { + currencyCode: credit.currencyCode, + excerptZero: true, + }); + }; + + /** + * Retrieves formatted discount percentage. + * @param credit + * @returns {string} + */ + protected discountPercentageFormatted = (credit): string => { + return credit.discountPercentage ? `${credit.discountPercentage}%` : ''; + }; + + /** + * Retrieves formatted adjustment amount. + * @param credit + * @returns {string} + */ + protected adjustmentFormatted = (credit): string => { + return this.formatMoney(credit.adjustment, { + currencyCode: credit.currencyCode, + excerptZero: true, + }); + }; + + /** + * Retrieves the formatted adjustment amount in local currency. + * @param {ICreditNote} credit + * @returns {string} + */ + protected adjustmentLocalFormatted = (credit): string => { + return formatNumber(credit.adjustmentLocal, { + currencyCode: this.context.organization.baseCurrency, + excerptZero: true, + }); + }; + + /** + * Retrieves the formatted total. + * @param credit + * @returns {string} + */ + protected totalFormatted = (credit): string => { + return formatNumber(credit.total, { + currencyCode: credit.currencyCode, + }); + }; + + /** + * Retrieves the formatted total in local currency. + * @param credit + * @returns {string} + */ + protected totalLocalFormatted = (credit): string => { + return formatNumber(credit.totalLocal, { + currencyCode: credit.currencyCode, + }); + }; + /** * Retrieves the entries of the credit note. * @param {ICreditNote} credit diff --git a/packages/server/src/services/Items/CreateItem.ts b/packages/server/src/services/Items/CreateItem.ts index 3ce935ee3..5864f2e09 100644 --- a/packages/server/src/services/Items/CreateItem.ts +++ b/packages/server/src/services/Items/CreateItem.ts @@ -24,7 +24,7 @@ export class CreateItem { /** * Authorize the creating item. - * @param {number} tenantId + * @param {number} tenantId * @param {IItemDTO} itemDTO */ async authorize(tenantId: number, itemDTO: IItemDTO) { diff --git a/packages/server/src/services/Purchases/Bills/BillGLEntries.ts b/packages/server/src/services/Purchases/Bills/BillGLEntries.ts index 9c1352e9a..a5daf87fe 100644 --- a/packages/server/src/services/Purchases/Bills/BillGLEntries.ts +++ b/packages/server/src/services/Purchases/Bills/BillGLEntries.ts @@ -52,10 +52,18 @@ export class BillGLEntries { {}, trx ); + // Find or create other expenses account. + const otherExpensesAccount = + await accountRepository.findOrCreateOtherExpensesAccount({}, trx); + // Find or create purchase discount account. + const purchaseDiscountAccount = + await accountRepository.findOrCreatePurchaseDiscountAccount({}, trx); const billLedger = this.getBillLedger( bill, APAccount.id, - taxPayableAccount.id + taxPayableAccount.id, + purchaseDiscountAccount.id, + otherExpensesAccount.id ); // Commit the GL enties on the storage. await this.ledgerStorage.commit(tenantId, billLedger, trx); @@ -102,6 +110,7 @@ export class BillGLEntries { return { debit: 0, credit: 0, + currencyCode: bill.currencyCode, exchangeRate: bill.exchangeRate || 1, @@ -130,13 +139,12 @@ export class BillGLEntries { private getBillItemEntry = R.curry( (bill: IBill, entry: IItemEntry, index: number): ILedgerEntry => { const commonJournalMeta = this.getBillCommonEntry(bill); - - const localAmount = bill.exchangeRate * entry.amountExludingTax; + const totalLocal = bill.exchangeRate * entry.totalExcludingTax; const landedCostAmount = sumBy(entry.allocatedCostEntries, 'cost'); return { ...commonJournalMeta, - debit: localAmount + landedCostAmount, + debit: totalLocal + landedCostAmount, accountId: ['inventory'].indexOf(entry.item.type) !== -1 ? entry.item.inventoryAccountId @@ -240,6 +248,52 @@ export class BillGLEntries { return nonZeroTaxEntries.map(transformTaxEntry); }; + /** + * Retrieves the purchase discount GL entry. + * @param {IBill} bill + * @param {number} purchaseDiscountAccountId + * @returns {ILedgerEntry} + */ + private getPurchaseDiscountEntry = ( + bill: IBill, + purchaseDiscountAccountId: number + ) => { + const commonEntry = this.getBillCommonEntry(bill); + + return { + ...commonEntry, + credit: bill.discountAmountLocal, + accountId: purchaseDiscountAccountId, + accountNormal: AccountNormal.DEBIT, + index: 1, + indexGroup: 40, + }; + }; + + /** + * Retrieves the purchase other charges GL entry. + * @param {IBill} bill + * @param {number} otherChargesAccountId + * @returns {ILedgerEntry} + */ + private getAdjustmentEntry = ( + bill: IBill, + otherExpensesAccountId: number + ) => { + const commonEntry = this.getBillCommonEntry(bill); + const adjustmentAmount = Math.abs(bill.adjustmentLocal); + + return { + ...commonEntry, + debit: bill.adjustmentLocal > 0 ? adjustmentAmount : 0, + credit: bill.adjustmentLocal < 0 ? adjustmentAmount : 0, + accountId: otherExpensesAccountId, + accountNormal: AccountNormal.DEBIT, + index: 1, + indexGroup: 40, + }; + }; + /** * Retrieves the given bill GL entries. * @param {IBill} bill @@ -249,7 +303,9 @@ export class BillGLEntries { private getBillGLEntries = ( bill: IBill, payableAccountId: number, - taxPayableAccountId: number + taxPayableAccountId: number, + purchaseDiscountAccountId: number, + otherExpensesAccountId: number ): ILedgerEntry[] => { const payableEntry = this.getBillPayableEntry(payableAccountId, bill); @@ -262,8 +318,24 @@ export class BillGLEntries { ); const taxEntries = this.getBillTaxEntries(bill, taxPayableAccountId); + const purchaseDiscountEntry = this.getPurchaseDiscountEntry( + bill, + purchaseDiscountAccountId + ); + const adjustmentEntry = this.getAdjustmentEntry( + bill, + otherExpensesAccountId + ); + // Allocate cost entries journal entries. - return [payableEntry, ...itemsEntries, ...landedCostEntries, ...taxEntries]; + return [ + payableEntry, + ...itemsEntries, + ...landedCostEntries, + ...taxEntries, + purchaseDiscountEntry, + adjustmentEntry, + ]; }; /** @@ -275,14 +347,17 @@ export class BillGLEntries { private getBillLedger = ( bill: IBill, payableAccountId: number, - taxPayableAccountId: number + taxPayableAccountId: number, + purchaseDiscountAccountId: number, + otherExpensesAccountId: number ) => { const entries = this.getBillGLEntries( bill, payableAccountId, - taxPayableAccountId + taxPayableAccountId, + purchaseDiscountAccountId, + otherExpensesAccountId ); - return new Ledger(entries); }; } diff --git a/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts b/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts index d940d79fa..05aa0311d 100644 --- a/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts +++ b/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts @@ -20,10 +20,21 @@ export class PurchaseInvoiceTransformer extends Transformer { 'formattedBalance', 'formattedDueAmount', 'formattedExchangeRate', + 'subtotalFormatted', 'subtotalLocalFormatted', + 'subtotalExcludingTaxFormatted', 'taxAmountWithheldLocalFormatted', + + 'discountAmountFormatted', + 'discountAmountLocalFormatted', + + 'discountPercentageFormatted', + + 'adjustmentFormatted', + 'adjustmentLocalFormatted', + 'totalFormatted', 'totalLocalFormatted', 'taxes', @@ -160,6 +171,63 @@ export class PurchaseInvoiceTransformer extends Transformer { }); }; + /** + * Retrieves the formatted discount amount. + * @param {IBill} bill + * @returns {string} + */ + protected discountAmountFormatted = (bill): string => { + return formatNumber(bill.discountAmount, { + currencyCode: bill.currencyCode, + excerptZero: true, + }); + }; + + /** + * Retrieves the formatted discount amount in local currency. + * @param {IBill} bill + * @returns {string} + */ + protected discountAmountLocalFormatted = (bill): string => { + return formatNumber(bill.discountAmountLocal, { + currencyCode: this.context.organization.baseCurrency, + excerptZero: true, + }); + }; + + /** + * Retrieves the formatted discount percentage. + * @param {IBill} bill + * @returns {string} + */ + protected discountPercentageFormatted = (bill): string => { + return bill.discountPercentage ? `${bill.discountPercentage}%` : ''; + }; + + /** + * Retrieves the formatted adjustment amount. + * @param {IBill} bill + * @returns {string} + */ + protected adjustmentFormatted = (bill): string => { + return formatNumber(bill.adjustment, { + currencyCode: bill.currencyCode, + excerptZero: true, + }); + }; + + /** + * Retrieves the formatted adjustment amount in local currency. + * @param {IBill} bill + * @returns {string} + */ + protected adjustmentLocalFormatted = (bill): string => { + return formatNumber(bill.adjustmentLocal, { + currencyCode: this.context.organization.baseCurrency, + excerptZero: true, + }); + }; + /** * Retrieves the total formatted. * @param {IBill} bill diff --git a/packages/server/src/services/Purchases/VendorCredits/VendorCreditGLEntries.ts b/packages/server/src/services/Purchases/VendorCredits/VendorCreditGLEntries.ts index fe269cef5..eca22e2e5 100644 --- a/packages/server/src/services/Purchases/VendorCredits/VendorCreditGLEntries.ts +++ b/packages/server/src/services/Purchases/VendorCredits/VendorCreditGLEntries.ts @@ -56,7 +56,7 @@ export default class VendorCreditGLEntries { return { ...commonEntity, - debit: vendorCredit.localAmount, + debit: vendorCredit.totalLocal, accountId: APAccountId, contactId: vendorCredit.vendorId, accountNormal: AccountNormal.CREDIT, @@ -77,11 +77,11 @@ export default class VendorCreditGLEntries { index: number ): ILedgerEntry => { const commonEntity = this.getVendorCreditGLCommonEntry(vendorCredit); - const localAmount = entry.amount * vendorCredit.exchangeRate; + const totalLocal = entry.totalExcludingTax * vendorCredit.exchangeRate; return { ...commonEntity, - credit: localAmount, + credit: totalLocal, index: index + 2, itemId: entry.itemId, itemQuantity: entry.quantity, @@ -94,6 +94,52 @@ export default class VendorCreditGLEntries { } ); + /** + * Retrieves the vendor credit discount GL entry. + * @param {IVendorCredit} vendorCredit + * @param {number} discountAccountId + * @returns {ILedgerEntry} + */ + public getDiscountEntry = ( + vendorCredit: IVendorCredit, + purchaseDiscountAccountId: number + ) => { + const commonEntry = this.getVendorCreditGLCommonEntry(vendorCredit); + + return { + ...commonEntry, + debit: vendorCredit.discountAmountLocal, + accountId: purchaseDiscountAccountId, + accountNormal: AccountNormal.DEBIT, + index: 1, + indexGroup: 40, + }; + }; + + /** + * Retrieves the vendor credit adjustment GL entry. + * @param {IVendorCredit} vendorCredit + * @param {number} adjustmentAccountId + * @returns {ILedgerEntry} + */ + public getAdjustmentEntry = ( + vendorCredit: IVendorCredit, + otherExpensesAccountId: number + ) => { + const commonEntry = this.getVendorCreditGLCommonEntry(vendorCredit); + const adjustmentAmount = Math.abs(vendorCredit.adjustmentLocal); + + return { + ...commonEntry, + credit: vendorCredit.adjustmentLocal > 0 ? adjustmentAmount : 0, + debit: vendorCredit.adjustmentLocal < 0 ? adjustmentAmount : 0, + accountId: otherExpensesAccountId, + accountNormal: AccountNormal.DEBIT, + index: 1, + indexGroup: 40, + }; + }; + /** * Retrieve the vendor credit GL entries. * @param {IVendorCredit} vendorCredit - @@ -102,7 +148,9 @@ export default class VendorCreditGLEntries { */ public getVendorCreditGLEntries = ( vendorCredit: IVendorCredit, - payableAccountId: number + payableAccountId: number, + purchaseDiscountAccountId: number, + otherExpensesAccountId: number ): ILedgerEntry[] => { const payableEntry = this.getVendorCreditPayableGLEntry( vendorCredit, @@ -111,7 +159,15 @@ export default class VendorCreditGLEntries { const getItemEntry = this.getVendorCreditGLItemEntry(vendorCredit); const itemsEntries = vendorCredit.entries.map(getItemEntry); - return [payableEntry, ...itemsEntries]; + const discountEntry = this.getDiscountEntry( + vendorCredit, + purchaseDiscountAccountId + ); + const adjustmentEntry = this.getAdjustmentEntry( + vendorCredit, + otherExpensesAccountId + ); + return [payableEntry, discountEntry, adjustmentEntry, ...itemsEntries]; }; /** @@ -158,10 +214,17 @@ export default class VendorCreditGLEntries { {}, trx ); + const purchaseDiscountAccount = + await accountRepository.findOrCreatePurchaseDiscountAccount({}, trx); + + const otherExpensesAccount = + await accountRepository.findOrCreateOtherExpensesAccount({}, trx); // Saves the vendor credit GL entries. const ledgerEntries = this.getVendorCreditGLEntries( vendorCredit, - APAccount.id + APAccount.id, + purchaseDiscountAccount.id, + otherExpensesAccount.id ); const ledger = new Ledger(ledgerEntries); diff --git a/packages/server/src/services/Purchases/VendorCredits/VendorCreditTransformer.ts b/packages/server/src/services/Purchases/VendorCredits/VendorCreditTransformer.ts index ba7625562..61e4d6736 100644 --- a/packages/server/src/services/Purchases/VendorCredits/VendorCreditTransformer.ts +++ b/packages/server/src/services/Purchases/VendorCredits/VendorCreditTransformer.ts @@ -17,6 +17,15 @@ export class VendorCreditTransformer extends Transformer { 'formattedCreatedAt', 'formattedCreditsRemaining', 'formattedInvoicedAmount', + + 'discountAmountFormatted', + 'discountPercentageFormatted', + 'discountAmountLocalFormatted', + + 'adjustmentFormatted', + 'adjustmentLocalFormatted', + + 'totalFormatted', 'entries', 'attachments', ]; @@ -33,7 +42,7 @@ export class VendorCreditTransformer extends Transformer { /** * Retireve formatted created at date. - * @param vendorCredit + * @param vendorCredit * @returns {string} */ protected formattedCreatedAt = (vendorCredit): string => { @@ -71,6 +80,63 @@ export class VendorCreditTransformer extends Transformer { }); }; + /** + * Retrieves the formatted discount amount. + * @param {IVendorCredit} credit + * @returns {string} + */ + protected discountAmountFormatted = (credit): string => { + return formatNumber(credit.discountAmount, { + currencyCode: credit.currencyCode, + excerptZero: true, + }); + }; + + /** + * Retrieves the formatted discount amount in local currency. + * @param {IVendorCredit} credit + * @returns {string} + */ + protected discountAmountLocalFormatted = (credit): string => { + return formatNumber(credit.discountAmountLocal, { + currencyCode: this.context.organization.baseCurrency, + excerptZero: true, + }); + }; + + /** + * Retrieves the formatted discount percentage. + * @param {IVendorCredit} credit + * @returns {string} + */ + protected discountPercentageFormatted = (credit): string => { + return credit.discountPercentage ? `${credit.discountPercentage}%` : ''; + }; + + /** + * Retrieves the formatted adjustment amount. + * @param {IVendorCredit} credit + * @returns {string} + */ + protected adjustmentFormatted = (credit): string => { + return formatNumber(credit.adjustment, { + currencyCode: credit.currencyCode, + excerptZero: true, + }); + }; + + /** + * Retrieves the formatted adjustment amount in local currency. + * @param {IVendorCredit} credit + * @returns {string} + */ + protected adjustmentLocalFormatted = (credit): string => { + return formatNumber(credit.adjustmentLocal, { + currencyCode: this.context.organization.baseCurrency, + excerptZero: true, + }); + }; + /** * Retrieves the formatted invoiced amount. * @param credit @@ -82,6 +148,15 @@ export class VendorCreditTransformer extends Transformer { }); }; + /** + * Retrieves the formatted total. + * @param {IVendorCredit} credit + * @returns {string} + */ + protected totalFormatted = (credit) => { + return formatNumber(credit.total, { currencyCode: credit.currencyCode }); + }; + /** * Retrieves the entries of the bill. * @param {IVendorCredit} vendorCredit diff --git a/packages/server/src/services/Sales/Estimates/GetEstimateMailTemplate.ts b/packages/server/src/services/Sales/Estimates/GetEstimateMailTemplate.ts new file mode 100644 index 000000000..11fb01bd0 --- /dev/null +++ b/packages/server/src/services/Sales/Estimates/GetEstimateMailTemplate.ts @@ -0,0 +1,75 @@ +import { Inject, Service } from 'typedi'; +import { + renderEstimateEmailTemplate, + EstimatePaymentEmailProps, +} from '@bigcapital/email-components'; +import { GetSaleEstimate } from './GetSaleEstimate'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { GetEstimateMailTemplateAttributesTransformer } from './GetEstimateMailTemplateAttributesTransformer'; +import { GetPdfTemplate } from '@/services/PdfTemplate/GetPdfTemplate'; + +@Service() +export class GetEstimateMailTemplate { + @Inject() + private getEstimateService: GetSaleEstimate; + + @Inject() + private transformer: TransformerInjectable; + + @Inject() + private getBrandingTemplate: GetPdfTemplate; + + /** + * Retrieves the mail template attributes of the given estimate. + * Estimate template attributes are composed of the estimate and branding template attributes. + * @param {number} tenantId + * @param {number} estimateId - Estimate id. + * @returns {Promise} + */ + public async getMailTemplateAttributes( + tenantId: number, + estimateId: number + ): Promise { + const estimate = await this.getEstimateService.getEstimate( + tenantId, + estimateId + ); + const brandingTemplate = await this.getBrandingTemplate.getPdfTemplate( + tenantId, + estimate.pdfTemplateId + ); + const mailTemplateAttributes = await this.transformer.transform( + tenantId, + estimate, + new GetEstimateMailTemplateAttributesTransformer(), + { + estimate, + brandingTemplate, + } + ); + return mailTemplateAttributes; + } + + /** + * Rertieves the mail template html content. + * @param {number} tenantId + * @param {number} estimateId + * @param overrideAttributes + * @returns + */ + public async getMailTemplate( + tenantId: number, + estimateId: number, + overrideAttributes?: Partial + ): Promise { + const attributes = await this.getMailTemplateAttributes( + tenantId, + estimateId + ); + const mergedAttributes = { + ...attributes, + ...overrideAttributes, + }; + return renderEstimateEmailTemplate(mergedAttributes); + } +} diff --git a/packages/server/src/services/Sales/Estimates/GetEstimateMailTemplateAttributesTransformer.ts b/packages/server/src/services/Sales/Estimates/GetEstimateMailTemplateAttributesTransformer.ts new file mode 100644 index 000000000..81d6d9a6c --- /dev/null +++ b/packages/server/src/services/Sales/Estimates/GetEstimateMailTemplateAttributesTransformer.ts @@ -0,0 +1,205 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class GetEstimateMailTemplateAttributesTransformer extends Transformer { + public includeAttributes = (): string[] => { + return [ + 'companyLogoUri', + 'companyName', + + 'estimateAmount', + + 'primaryColor', + + 'estimateAmount', + 'estimateMessage', + + 'dueDate', + 'dueDateLabel', + + 'estimateNumber', + 'estimateNumberLabel', + + 'total', + 'totalLabel', + + 'subtotal', + 'subtotalLabel', + + 'discount', + 'discountLabel', + + 'adjustment', + 'adjustmentLabel', + + 'dueAmount', + 'dueAmountLabel', + + 'viewEstimateButtonLabel', + 'viewEstimateButtonUrl', + + 'items', + ]; + }; + + /** + * Exclude all attributes. + * @returns {string[]} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Company logo uri. + * @returns {string} + */ + public companyLogoUri(): string { + return this.options.brandingTemplate?.companyLogoUri; + } + + /** + * Company name. + * @returns {string} + */ + public companyName(): string { + return this.context.organization.name; + } + + /** + * Primary color. + * @returns {string} + */ + public primaryColor(): string { + return this.options?.brandingTemplate?.attributes?.primaryColor; + } + + /** + * Estimate number. + * @returns {string} + */ + public estimateNumber(): string { + return this.options.estimate.estimateNumber; + } + + /** + * Estimate number label. + * @returns {string} + */ + public estimateNumberLabel(): string { + return 'Estimate No: {estimateNumber}'; + } + + /** + * Expiration date. + * @returns {string} + */ + public expirationDate(): string { + return this.options.estimate.formattedExpirationDate; + } + + /** + * Expiration date label. + * @returns {string} + */ + public expirationDateLabel(): string { + return 'Expiration Date: {expirationDate}'; + } + + /** + * Estimate total. + */ + public total(): string { + return this.options.estimate.totalFormatted; + } + + /** + * Estimate total label. + * @returns {string} + */ + public totalLabel(): string { + return 'Total'; + } + + /** + * Estimate discount. + * @returns {string} + */ + public discount(): string { + return this.options.estimate?.discountAmountFormatted; + } + + /** + * Estimate discount label. + * @returns {string} + */ + public discountLabel(): string { + return 'Discount'; + } + + /** + * Estimate adjustment. + * @returns {string} + */ + public adjustment(): string { + return this.options.estimate?.adjustmentFormatted; + } + + /** + * Estimate adjustment label. + * @returns {string} + */ + public adjustmentLabel(): string { + return 'Adjustment'; + } + + /** + * Estimate subtotal. + */ + public subtotal(): string { + return this.options.estimate.formattedSubtotal; + } + + /** + * Estimate subtotal label. + * @returns {string} + */ + public subtotalLabel(): string { + return 'Subtotal'; + } + + /** + * Estimate mail items attributes. + */ + public items(): any[] { + return this.item( + this.options.estimate.entries, + new GetEstimateMailTemplateEntryAttributesTransformer() + ); + } +} + +class GetEstimateMailTemplateEntryAttributesTransformer extends Transformer { + public includeAttributes = (): string[] => { + return ['label', 'quantity', 'rate', 'total']; + }; + + public excludeAttributes = (): string[] => { + return ['*']; + }; + + public label(entry): string { + return entry?.item?.name; + } + + public quantity(entry): string { + return entry?.quantity; + } + + public rate(entry): string { + return entry?.rateFormatted; + } + + public total(entry): string { + return entry?.totalFormatted; + } +} diff --git a/packages/server/src/services/Sales/Estimates/GetSaleEstimateMailState.ts b/packages/server/src/services/Sales/Estimates/GetSaleEstimateMailState.ts new file mode 100644 index 000000000..bc5e2f227 --- /dev/null +++ b/packages/server/src/services/Sales/Estimates/GetSaleEstimateMailState.ts @@ -0,0 +1,52 @@ +import { Inject, Service } from 'typedi'; +import { SendSaleEstimateMail } from './SendSaleEstimateMail'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { GetSaleEstimateMailStateTransformer } from './GetSaleEstimateMailStateTransformer'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; + +@Service() +export class GetSaleEstimateMailState { + @Inject() + private estimateMail: SendSaleEstimateMail; + + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieves the estimate mail state of the given sale estimate. + * Estimate mail state includes the mail options, branding attributes and the estimate details. + * @param {number} tenantId + * @param {number} saleEstimateId + * @returns {Promise} + */ + async getEstimateMailState( + tenantId: number, + saleEstimateId: number + ): Promise { + const { SaleEstimate } = this.tenancy.models(tenantId); + + const saleEstimate = await SaleEstimate.query() + .findById(saleEstimateId) + .withGraphFetched('customer') + .withGraphFetched('entries.item') + .withGraphFetched('pdfTemplate') + .throwIfNotFound(); + + const mailOptions = await this.estimateMail.getMailOptions( + tenantId, + saleEstimateId + ); + const transformed = await this.transformer.transform( + tenantId, + saleEstimate, + new GetSaleEstimateMailStateTransformer(), + { + mailOptions, + } + ); + return transformed; + } +} diff --git a/packages/server/src/services/Sales/Estimates/GetSaleEstimateMailStateTransformer.ts b/packages/server/src/services/Sales/Estimates/GetSaleEstimateMailStateTransformer.ts new file mode 100644 index 000000000..06fdf5436 --- /dev/null +++ b/packages/server/src/services/Sales/Estimates/GetSaleEstimateMailStateTransformer.ts @@ -0,0 +1,180 @@ +import { ItemEntryTransformer } from '../Invoices/ItemEntryTransformer'; +import { SaleEstimateTransfromer } from './SaleEstimateTransformer'; + +export class GetSaleEstimateMailStateTransformer extends SaleEstimateTransfromer { + public excludeAttributes = (): string[] => { + return ['*']; + }; + + public includeAttributes = (): string[] => { + return [ + 'estimateDate', + 'estimateDateFormatted', + + 'expirationDate', + 'expirationDateFormatted', + + 'total', + 'totalFormatted', + + 'subtotal', + 'subtotalFormatted', + + 'discountAmount', + 'discountAmountFormatted', + 'discountPercentage', + 'discountPercentageFormatted', + 'discountLabel', + + 'adjustment', + 'adjustmentFormatted', + 'adjustmentLabel', + + 'estimateNumber', + 'entries', + + 'companyName', + 'companyLogoUri', + + 'primaryColor', + 'customerName', + ]; + }; + + /** + * Retrieves the customer name of the invoice. + * @returns {string} + */ + protected customerName = (invoice) => { + return invoice.customer.displayName; + }; + + /** + * Retrieves the company name. + * @returns {string} + */ + protected companyName = () => { + return this.context.organization.name; + }; + + /** + * Retrieves the company logo uri. + * @returns {string | null} + */ + protected companyLogoUri = (invoice) => { + return invoice.pdfTemplate?.companyLogoUri || null; + }; + + /** + * Retrieves the primary color. + * @returns {string} + */ + protected primaryColor = (invoice) => { + return invoice.pdfTemplate?.attributes?.primaryColor || null; + }; + + /** + * Retrieves the estimate number. + */ + protected estimateDateFormatted = (estimate) => { + return this.formattedEstimateDate(estimate); + }; + + /** + * Retrieves the expiration date of the estimate. + * @param estimate + * @returns {string} + */ + protected expirationDateFormatted = (estimate) => { + return this.formattedExpirationDate(estimate); + }; + + /** + * Retrieves the total amount of the estimate. + * @param estimate + * @returns + */ + protected total(estimate) { + return estimate.amount; + } + + /** + * Retrieves the subtotal amount of the estimate. + * @param estimate + * @returns {number} + */ + protected subtotal(estimate) { + return estimate.amount; + } + + /** + * Retrieves the discount label of the estimate. + * @param estimate + * @returns {string} + */ + protected discountLabel(estimate) { + return estimate.discountType === 'percentage' + ? `Discount [${estimate.discountPercentageFormatted}]` + : 'Discount'; + } + + /** + * Retrieves the formatted subtotal of the estimate. + * @param estimate + * @returns {string} + */ + protected subtotalFormatted = (estimate) => { + return this.formattedSubtotal(estimate); + }; + + /** + * Retrieves the estimate entries. + * @param invoice + * @returns {Array} + */ + protected entries = (invoice) => { + return this.item( + invoice.entries, + new GetSaleEstimateMailStateEntryTransformer(), + { + currencyCode: invoice.currencyCode, + } + ); + }; + + /** + * Merges the mail options with the invoice object. + */ + public transform = (object: any) => { + return { + ...this.options.mailOptions, + ...object, + }; + }; +} + +class GetSaleEstimateMailStateEntryTransformer extends ItemEntryTransformer { + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Item name. + * @param entry + * @returns + */ + public name = (entry) => { + return entry.item.name; + }; + + public includeAttributes = (): string[] => { + return [ + 'name', + 'quantity', + 'unitPrice', + 'unitPriceFormatted', + 'total', + 'totalFormatted', + ]; + }; +} diff --git a/packages/server/src/services/Sales/Estimates/SaleEstimateTransformer.ts b/packages/server/src/services/Sales/Estimates/SaleEstimateTransformer.ts index 1a7d6990a..91e7d0ab5 100644 --- a/packages/server/src/services/Sales/Estimates/SaleEstimateTransformer.ts +++ b/packages/server/src/services/Sales/Estimates/SaleEstimateTransformer.ts @@ -18,6 +18,11 @@ export class SaleEstimateTransfromer extends Transformer { 'formattedDeliveredAtDate', 'formattedApprovedAtDate', 'formattedRejectedAtDate', + 'discountAmountFormatted', + 'discountPercentageFormatted', + 'adjustmentFormatted', + 'totalFormatted', + 'totalLocalFormatted', 'formattedCreatedAt', 'entries', 'attachments', @@ -98,6 +103,62 @@ export class SaleEstimateTransfromer extends Transformer { return formatNumber(estimate.amount, { money: false }); }; + /** + * Retrieves formatted discount amount. + * @param estimate + * @returns {string} + */ + protected discountAmountFormatted = (estimate: ISaleEstimate): string => { + return formatNumber(estimate.discountAmount, { + currencyCode: estimate.currencyCode, + excerptZero: true, + }); + }; + + /** + * Retrieves formatted discount percentage. + * @param estimate + * @returns {string} + */ + protected discountPercentageFormatted = (estimate: ISaleEstimate): string => { + return estimate.discountPercentage + ? `${estimate.discountPercentage}%` + : ''; + }; + + /** + * Retrieves formatted adjustment amount. + * @param estimate + * @returns {string} + */ + protected adjustmentFormatted = (estimate: ISaleEstimate): string => { + return this.formatMoney(estimate.adjustment, { + currencyCode: estimate.currencyCode, + excerptZero: true, + }); + }; + + /** + * Retrieves the formatted estimate total. + * @returns {string} + */ + protected totalFormatted = (estimate: ISaleEstimate): string => { + return this.formatMoney(estimate.total, { + currencyCode: estimate.currencyCode, + }); + }; + + /** + * Retrieves the formatted estimate total in local currency. + * @param estimate + * @returns {string} + */ + protected totalLocalFormatted = (estimate: ISaleEstimate): string => { + return this.formatMoney(estimate.totalLocal, { + currencyCode: estimate.currencyCode, + }); + }; + /** * 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 f9972e282..ec7c6afa8 100644 --- a/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts +++ b/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts @@ -21,6 +21,7 @@ import { SaleEstimateNotifyBySms } from './SaleEstimateSmsNotify'; import { SaleEstimatesPdf } from './SaleEstimatesPdf'; import { SendSaleEstimateMail } from './SendSaleEstimateMail'; import { GetSaleEstimateState } from './GetSaleEstimateState'; +import { GetSaleEstimateMailState } from './GetSaleEstimateMailState'; @Service() export class SaleEstimatesApplication { @@ -57,6 +58,9 @@ export class SaleEstimatesApplication { @Inject() private sendEstimateMailService: SendSaleEstimateMail; + @Inject() + private getSaleEstimateMailStateService: GetSaleEstimateMailState; + @Inject() private getSaleEstimateStateService: GetSaleEstimateState; @@ -220,6 +224,18 @@ export class SaleEstimatesApplication { ); } + /** + * Retrieve the HTML content of the given sale estimate. + * @param {number} tenantId + * @param {number} saleEstimateId + */ + public getSaleEstimateHtml(tenantId: number, saleEstimateId: number) { + return this.saleEstimatesPdfService.saleEstimateHtml( + tenantId, + saleEstimateId + ); + } + /** * Send the reminder mail of the given sale estimate. * @param {number} tenantId @@ -238,6 +254,22 @@ export class SaleEstimatesApplication { ); } + /** + * Retrieves the sale estimate mail state. + * @param {number} tenantId + * @param {number} saleEstimateId + * @returns {Promise} + */ + public getSaleEstimateMailState( + tenantId: number, + saleEstimateId: number + ): Promise { + return this.getSaleEstimateMailStateService.getEstimateMailState( + tenantId, + saleEstimateId + ); + } + /** * Retrieves the default mail options of the given sale estimate. * @param {number} tenantId diff --git a/packages/server/src/services/Sales/Estimates/SaleEstimatesPdf.ts b/packages/server/src/services/Sales/Estimates/SaleEstimatesPdf.ts index f0750b43c..4cb6c8f3b 100644 --- a/packages/server/src/services/Sales/Estimates/SaleEstimatesPdf.ts +++ b/packages/server/src/services/Sales/Estimates/SaleEstimatesPdf.ts @@ -1,13 +1,12 @@ import { Inject, Service } from 'typedi'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; -import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; import { GetSaleEstimate } from './GetSaleEstimate'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { SaleEstimatePdfTemplate } from '../Invoices/SaleEstimatePdfTemplate'; import { transformEstimateToPdfTemplate } from './utils'; -import { EstimatePdfBrandingAttributes } from './constants'; import events from '@/subscribers/events'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import { renderEstimatePaperTemplateHtml, EstimatePaperTemplateProps } from '@bigcapital/pdf-templates'; @Service() export class SaleEstimatesPdf { @@ -17,9 +16,6 @@ export class SaleEstimatesPdf { @Inject() private chromiumlyTenancy: ChromiumlyTenancy; - @Inject() - private templateInjectable: TemplateInjectable; - @Inject() private getSaleEstimate: GetSaleEstimate; @@ -29,6 +25,22 @@ export class SaleEstimatesPdf { @Inject() private eventPublisher: EventPublisher; + /** + * Retrieve sale estimate html content. + * @param {number} tenantId - + * @param {number} invoiceId - + */ + public async saleEstimateHtml( + tenantId: number, + estimateId: number + ): Promise { + const brandingAttributes = await this.getEstimateBrandingAttributes( + tenantId, + estimateId + ); + return renderEstimatePaperTemplateHtml({ ...brandingAttributes }); + } + /** * Retrieve sale invoice pdf content. * @param {number} tenantId - @@ -42,15 +54,10 @@ export class SaleEstimatesPdf { tenantId, saleEstimateId ); - const brandingAttributes = await this.getEstimateBrandingAttributes( - tenantId, - saleEstimateId - ); - const htmlContent = await this.templateInjectable.render( - tenantId, - 'modules/estimate-regular', - brandingAttributes - ); + // Retireves the sale estimate html. + const htmlContent = await this.saleEstimateHtml(tenantId, saleEstimateId); + + // Converts the html content to pdf. const content = await this.chromiumlyTenancy.convertHtmlContent( tenantId, htmlContent @@ -88,7 +95,7 @@ export class SaleEstimatesPdf { async getEstimateBrandingAttributes( tenantId: number, estimateId: number - ): Promise { + ): Promise { const { PdfTemplate } = this.tenancy.models(tenantId); const saleEstimate = await this.getSaleEstimate.getEstimate( tenantId, diff --git a/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts index 1c1ada66a..db5b66713 100644 --- a/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts +++ b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts @@ -17,6 +17,7 @@ import { mergeAndValidateMailOptions } from '@/services/MailNotification/utils'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import events from '@/subscribers/events'; import { transformEstimateToMailDataArgs } from './utils'; +import { GetEstimateMailTemplate } from './GetEstimateMailTemplate'; @Service() export class SendSaleEstimateMail { @@ -32,12 +33,15 @@ export class SendSaleEstimateMail { @Inject() private contactMailNotification: ContactMailNotification; - @Inject('agenda') - private agenda: any; + @Inject() + private getEstimateMailTemplate: GetEstimateMailTemplate; @Inject() private eventPublisher: EventPublisher; + @Inject('agenda') + private agenda: any; + /** * Triggers the reminder mail of the given sale estimate. * @param {number} tenantId - @@ -76,7 +80,13 @@ export class SendSaleEstimateMail { tenantId, estimateId ); - return transformEstimateToMailDataArgs(estimate); + const commonArgs = await this.contactMailNotification.getCommonFormatArgs( + tenantId + ); + return { + ...commonArgs, + ...transformEstimateToMailDataArgs(estimate), + }; }; /** @@ -132,9 +142,45 @@ export class SendSaleEstimateMail { mailOptions, formatterArgs ); - return { ...formattedOptions }; + // Retrieves the estimate mail template. + const message = await this.getEstimateMailTemplate.getMailTemplate( + tenantId, + saleEstimateId, + { + message: formattedOptions.message, + preview: formattedOptions.message, + } + ); + return { ...formattedOptions, message }; }; + /** + * Retrieves the formatted mail options. + * @param {number} tenantId + * @param {number} saleEstimateId + * @param {SaleEstimateMailOptionsDTO} messageOptions + * @returns + */ + public async getFormattedMailOptions( + tenantId: number, + saleEstimateId: number, + messageOptions: SaleEstimateMailOptionsDTO + ): Promise { + const defaultMessageOptions = await this.getMailOptions( + tenantId, + saleEstimateId + ); + const parsedMessageOptions = mergeAndValidateMailOptions( + defaultMessageOptions, + messageOptions + ); + return this.formatMailOptions( + tenantId, + saleEstimateId, + parsedMessageOptions + ); + } + /** * Sends the mail notification of the given sale estimate. * @param {number} tenantId @@ -147,20 +193,10 @@ export class SendSaleEstimateMail { saleEstimateId: number, messageOptions: SaleEstimateMailOptionsDTO ): Promise { - const localMessageOpts = await this.getMailOptions( - tenantId, - saleEstimateId - ); - // Overrides and validates the given mail options. - const parsedMessageOptions = mergeAndValidateMailOptions( - localMessageOpts, - messageOptions - ) as SaleEstimateMailOptions; - - const formattedOptions = await this.formatMailOptions( + const formattedOptions = await this.getFormattedMailOptions( tenantId, saleEstimateId, - parsedMessageOptions + messageOptions ); const mail = new Mail() .setSubject(formattedOptions.subject) @@ -182,7 +218,6 @@ export class SendSaleEstimateMail { }, ]); } - const eventPayload = { tenantId, saleEstimateId, diff --git a/packages/server/src/services/Sales/Estimates/constants.ts b/packages/server/src/services/Sales/Estimates/constants.ts index 798e8f090..7580c260c 100644 --- a/packages/server/src/services/Sales/Estimates/constants.ts +++ b/packages/server/src/services/Sales/Estimates/constants.ts @@ -1,18 +1,17 @@ export const DEFAULT_ESTIMATE_REMINDER_MAIL_SUBJECT = 'Estimate {Estimate Number} is awaiting your approval'; -export const DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT = `

Dear {Customer Name}

-

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

-

-Estimate #{Estimate Number}
-Expiration Date : {Estimate Expiration Date}
-Amount : {Estimate Amount}
-

+export const DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT = `Hi {Customer Name}, -

-Regards
-{Company Name} -

-`; +Here's estimate # {Estimate Number} for {Estimate Amount} + +This estimate is valid until {Estimate Expiration Date}, and we’re happy to discuss any adjustments you or questions may have. + +Please find your estimate attached to this email for your reference. + +If you have any questions, please let us know. + +Thanks, +{Company Name}`; export const ERRORS = { SALE_ESTIMATE_NOT_FOUND: 'SALE_ESTIMATE_NOT_FOUND', @@ -255,18 +254,27 @@ export interface EstimatePdfBrandingAttributes { companyAddress: string; billedToLabel: string; + // # Total total: string; totalLabel: string; showTotal: boolean; + // # Discount + discount: string; + showDiscount: boolean; + discountLabel: string; + + // # Subtotal subtotal: string; subtotalLabel: string; showSubtotal: boolean; + // # Customer Note showCustomerNote: boolean; customerNote: string; customerNoteLabel: string; + // # Terms & Conditions showTermsConditions: boolean; termsConditions: string; termsConditionsLabel: string; diff --git a/packages/server/src/services/Sales/Estimates/utils.ts b/packages/server/src/services/Sales/Estimates/utils.ts index efd301af3..e67a3e235 100644 --- a/packages/server/src/services/Sales/Estimates/utils.ts +++ b/packages/server/src/services/Sales/Estimates/utils.ts @@ -1,9 +1,9 @@ +import { EstimatePaperTemplateProps } from '@bigcapital/pdf-templates'; import { contactAddressTextFormat } from '@/utils/address-text-format'; -import { EstimatePdfBrandingAttributes } from './constants'; export const transformEstimateToPdfTemplate = ( estimate -): Partial => { +): Partial => { return { expirationDate: estimate.formattedExpirationDate, estimateNumebr: estimate.estimateNumber, @@ -13,13 +13,20 @@ export const transformEstimateToPdfTemplate = ( description: entry.description, rate: entry.rateFormatted, quantity: entry.quantityFormatted, + discount: entry.discountFormatted, total: entry.totalFormatted, })), - total: estimate.formattedSubtotal, + total: estimate.totalFormatted, subtotal: estimate.formattedSubtotal, + adjustment: estimate.adjustmentFormatted, customerNote: estimate.note, termsConditions: estimate.termsConditions, customerAddress: contactAddressTextFormat(estimate.customer), + showLineDiscount: estimate.entries.some((entry) => entry.discountFormatted), + discount: estimate.discountAmountFormatted, + discountLabel: estimate.discountPercentageFormatted + ? `Discount [${estimate.discountPercentageFormatted}]` + : 'Discount', }; }; diff --git a/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts b/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts index e6a080c04..bf428e6b0 100644 --- a/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts @@ -154,6 +154,6 @@ export class CommandSaleInvoiceDTOTransformer { * @returns {number} */ private getDueBalanceItemEntries = (entries: ItemEntry[]) => { - return sumBy(entries, (e) => e.amount); + return sumBy(entries, (e) => e.total); }; } diff --git a/packages/server/src/services/Sales/Invoices/GetInvoicePaymentMailAttributesTransformer.ts b/packages/server/src/services/Sales/Invoices/GetInvoicePaymentMailAttributesTransformer.ts index 1ac116064..749295371 100644 --- a/packages/server/src/services/Sales/Invoices/GetInvoicePaymentMailAttributesTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/GetInvoicePaymentMailAttributesTransformer.ts @@ -23,6 +23,15 @@ export class GetInvoiceMailTemplateAttributesTransformer extends Transformer { 'invoiceNumber', 'invoiceNumberLabel', + 'subtotal', + 'subtotalLabel', + + 'discount', + 'discountLabel', + + 'adjustment', + 'adjustmentLabel', + 'total', 'totalLabel', @@ -76,6 +85,30 @@ export class GetInvoiceMailTemplateAttributesTransformer extends Transformer { return 'Invoice # {invoiceNumber}'; } + public subtotal(): string { + return this.options.invoice?.subtotalFormatted; + } + + public subtotalLabel(): string { + return 'Subtotal'; + } + + public discount(): string { + return this.options.invoice?.discountAmountFormatted; + } + + public discountLabel(): string { + return 'Discount'; + } + + public adjustment(): string { + return this.options.invoice?.adjustmentFormatted; + } + + public adjustmentLabel(): string { + return 'Adjustment'; + } + public total(): string { return this.options.invoice?.totalFormatted; } diff --git a/packages/server/src/services/Sales/Invoices/GetSaleInvoiceMailStateTransformer.ts b/packages/server/src/services/Sales/Invoices/GetSaleInvoiceMailStateTransformer.ts index 128080f38..259ea31e7 100644 --- a/packages/server/src/services/Sales/Invoices/GetSaleInvoiceMailStateTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/GetSaleInvoiceMailStateTransformer.ts @@ -28,6 +28,15 @@ export class GetSaleInvoiceMailStateTransformer extends SaleInvoiceTransformer { 'total', 'totalFormatted', + 'discountAmount', + 'discountAmountFormatted', + 'discountPercentage', + 'discountPercentageFormatted', + 'discountLabel', + + 'adjustment', + 'adjustmentFormatted', + 'subtotal', 'subtotalFormatted', @@ -76,6 +85,17 @@ export class GetSaleInvoiceMailStateTransformer extends SaleInvoiceTransformer { return invoice.pdfTemplate?.attributes?.primaryColor; }; + /** + * Retrieves the discount label of the estimate. + * @param estimate + * @returns {string} + */ + protected discountLabel(invoice) { + return invoice.discountType === 'percentage' + ? `Discount [${invoice.discountPercentageFormatted}]` + : 'Discount'; + } + /** * * @param invoice diff --git a/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts b/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts index 61c575f67..570da2566 100644 --- a/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts +++ b/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts @@ -44,18 +44,31 @@ export class SaleInvoiceGLEntries { // Find or create the A/R account. const ARAccount = await accountRepository.findOrCreateAccountReceivable( - saleInvoice.currencyCode, {}, trx + saleInvoice.currencyCode, + {}, + trx ); // Find or create tax payable account. const taxPayableAccount = await accountRepository.findOrCreateTaxPayable( {}, trx ); + // Find or create the discount expense account. + const discountAccount = await accountRepository.findOrCreateDiscountAccount( + {}, + trx + ); + // Find or create the other charges account. + const otherChargesAccount = + await accountRepository.findOrCreateOtherChargesAccount({}, trx); + // Retrieves the ledger of the invoice. const ledger = this.getInvoiceGLedger( saleInvoice, ARAccount.id, - taxPayableAccount.id + taxPayableAccount.id, + discountAccount.id, + otherChargesAccount.id ); // Commits the ledger entries to the storage as UOW. await this.ledegrRepository.commit(tenantId, ledger, trx); @@ -107,12 +120,16 @@ export class SaleInvoiceGLEntries { public getInvoiceGLedger = ( saleInvoice: ISaleInvoice, ARAccountId: number, - taxPayableAccountId: number + taxPayableAccountId: number, + discountAccountId: number, + otherChargesAccountId: number ): ILedger => { const entries = this.getInvoiceGLEntries( saleInvoice, ARAccountId, - taxPayableAccountId + taxPayableAccountId, + discountAccountId, + otherChargesAccountId ); return new Ledger(entries); }; @@ -127,6 +144,7 @@ export class SaleInvoiceGLEntries { ): Partial => ({ credit: 0, debit: 0, + currencyCode: saleInvoice.currencyCode, exchangeRate: saleInvoice.exchangeRate, @@ -181,7 +199,7 @@ export class SaleInvoiceGLEntries { index: number ): ILedgerEntry => { const commonEntry = this.getInvoiceGLCommonEntry(saleInvoice); - const localAmount = entry.amountExludingTax * saleInvoice.exchangeRate; + const localAmount = entry.totalExcludingTax * saleInvoice.exchangeRate; return { ...commonEntry, @@ -249,6 +267,50 @@ export class SaleInvoiceGLEntries { return nonZeroTaxEntries.map(transformTaxEntry); }; + /** + * Retrieves the invoice discount GL entry. + * @param {ISaleInvoice} saleInvoice + * @param {number} discountAccountId + * @returns {ILedgerEntry} + */ + private getInvoiceDiscountEntry = ( + saleInvoice: ISaleInvoice, + discountAccountId: number + ): ILedgerEntry => { + const commonEntry = this.getInvoiceGLCommonEntry(saleInvoice); + + return { + ...commonEntry, + debit: saleInvoice.discountAmountLocal, + accountId: discountAccountId, + accountNormal: AccountNormal.CREDIT, + index: 1, + } as ILedgerEntry; + }; + + /** + * Retrieves the invoice adjustment GL entry. + * @param {ISaleInvoice} saleInvoice + * @param {number} adjustmentAccountId + * @returns {ILedgerEntry} + */ + private getAdjustmentEntry = ( + saleInvoice: ISaleInvoice, + otherChargesAccountId: number + ): ILedgerEntry => { + const commonEntry = this.getInvoiceGLCommonEntry(saleInvoice); + const adjustmentAmount = Math.abs(saleInvoice.adjustmentLocal); + + return { + ...commonEntry, + debit: saleInvoice.adjustmentLocal < 0 ? adjustmentAmount : 0, + credit: saleInvoice.adjustmentLocal > 0 ? adjustmentAmount : 0, + accountId: otherChargesAccountId, + accountNormal: AccountNormal.CREDIT, + index: 1, + }; + }; + /** * Retrieves the invoice GL entries. * @param {ISaleInvoice} saleInvoice @@ -258,7 +320,9 @@ export class SaleInvoiceGLEntries { public getInvoiceGLEntries = ( saleInvoice: ISaleInvoice, ARAccountId: number, - taxPayableAccountId: number + taxPayableAccountId: number, + discountAccountId: number, + otherChargesAccountId: number ): ILedgerEntry[] => { const receivableEntry = this.getInvoiceReceivableEntry( saleInvoice, @@ -271,6 +335,20 @@ export class SaleInvoiceGLEntries { saleInvoice, taxPayableAccountId ); - return [receivableEntry, ...creditEntries, ...taxEntries]; + const discountEntry = this.getInvoiceDiscountEntry( + saleInvoice, + discountAccountId + ); + const adjustmentEntry = this.getAdjustmentEntry( + saleInvoice, + otherChargesAccountId + ); + return [ + receivableEntry, + ...creditEntries, + ...taxEntries, + discountEntry, + adjustmentEntry, + ]; }; } diff --git a/packages/server/src/services/Sales/Invoices/ItemEntryTransformer.ts b/packages/server/src/services/Sales/Invoices/ItemEntryTransformer.ts index dbaea4862..5fcf33b85 100644 --- a/packages/server/src/services/Sales/Invoices/ItemEntryTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/ItemEntryTransformer.ts @@ -1,4 +1,4 @@ -import { IItemEntry } from '@/interfaces'; +import { DiscountType, IItemEntry } from '@/interfaces'; import { Transformer } from '@/lib/Transformer/Transformer'; import { formatNumber } from '@/utils'; @@ -8,7 +8,13 @@ export class ItemEntryTransformer extends Transformer { * @returns {Array} */ public includeAttributes = (): string[] => { - return ['quantityFormatted', 'rateFormatted', 'totalFormatted']; + return [ + 'quantityFormatted', + 'rateFormatted', + 'totalFormatted', + 'discountFormatted', + 'discountAmountFormatted', + ]; }; /** @@ -43,4 +49,34 @@ export class ItemEntryTransformer extends Transformer { money: false, }); }; + + /** + * Retrieves the formatted discount of item entry. + * @param {IItemEntry} entry + * @returns {string} + */ + protected discountFormatted = (entry: IItemEntry): string => { + if (!entry.discount) { + return ''; + } + return entry.discountType === DiscountType.Percentage + ? `${entry.discount}%` + : formatNumber(entry.discount, { + currencyCode: this.context.currencyCode, + money: false, + }); + }; + + /** + * Retrieves the formatted discount amount of item entry. + * @param {IItemEntry} entry + * @returns {string} + */ + protected discountAmountFormatted = (entry: IItemEntry): string => { + return formatNumber(entry.discountAmount, { + currencyCode: this.context.currencyCode, + money: false, + excerptZero: true, + }); + }; } diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts b/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts index a664ead35..1c492b9ce 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoicePdf.ts @@ -1,15 +1,15 @@ import { Inject, Service } from 'typedi'; -import { renderInvoicePaperTemplateHtml } from '@bigcapital/pdf-templates'; +import { + renderInvoicePaperTemplateHtml, + InvoicePaperTemplateProps, +} from '@bigcapital/pdf-templates'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; -import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; import { GetSaleInvoice } from './GetSaleInvoice'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { transformInvoiceToPdfTemplate } from './utils'; -import { InvoicePdfTemplateAttributes } from '@/interfaces'; import { SaleInvoicePdfTemplate } from './SaleInvoicePdfTemplate'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import events from '@/subscribers/events'; -import { renderInvoicePaymentEmail } from '@bigcapital/email-components'; @Service() export class SaleInvoicePdf { @@ -102,7 +102,7 @@ export class SaleInvoicePdf { async getInvoiceBrandingAttributes( tenantId: number, invoiceId: number - ): Promise { + ): Promise { const { PdfTemplate } = this.tenancy.models(tenantId); const invoice = await this.getInvoiceService.getSaleInvoice( diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts b/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts index ef5fe5e12..05dce98b7 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts @@ -3,6 +3,7 @@ import { formatNumber } from 'utils'; import { SaleInvoiceTaxEntryTransformer } from './SaleInvoiceTaxEntryTransformer'; import { ItemEntryTransformer } from './ItemEntryTransformer'; import { AttachmentTransformer } from '@/services/Attachments/AttachmentTransformer'; +import { DiscountType } from '@/interfaces'; export class SaleInvoiceTransformer extends Transformer { /** @@ -23,6 +24,9 @@ export class SaleInvoiceTransformer extends Transformer { 'subtotalExludingTaxFormatted', 'taxAmountWithheldFormatted', 'taxAmountWithheldLocalFormatted', + 'discountAmountFormatted', + 'discountPercentageFormatted', + 'adjustmentFormatted', 'totalFormatted', 'totalLocalFormatted', 'taxes', @@ -158,6 +162,41 @@ export class SaleInvoiceTransformer extends Transformer { }); }; + /** + * Retrieves formatted discount amount. + * @param invoice + * @returns {string} + */ + protected discountAmountFormatted = (invoice): string => { + return formatNumber(invoice.discountAmount, { + currencyCode: invoice.currencyCode, + excerptZero: true, + }); + }; + + /** + * Retrieves formatted discount percentage. + * @param invoice + * @returns {string} + */ + protected discountPercentageFormatted = (invoice): string => { + return invoice.discountType === DiscountType.Percentage + ? `${invoice.discount}%` + : ''; + }; + + /** + * Retrieves formatted adjustment amount. + * @param invoice + * @returns {string} + */ + protected adjustmentFormatted = (invoice): string => { + return this.formatMoney(invoice.adjustment, { + currencyCode: invoice.currencyCode, + excerptZero: true, + }); + }; + /** * Retrieves formatted total in foreign currency. * @param invoice diff --git a/packages/server/src/services/Sales/Invoices/utils.ts b/packages/server/src/services/Sales/Invoices/utils.ts index 0db99bf55..0de33fc81 100644 --- a/packages/server/src/services/Sales/Invoices/utils.ts +++ b/packages/server/src/services/Sales/Invoices/utils.ts @@ -1,6 +1,7 @@ import { pickBy } from 'lodash'; -import { InvoicePdfTemplateAttributes, ISaleInvoice } from '@/interfaces'; +import { ISaleInvoice } from '@/interfaces'; import { contactAddressTextFormat } from '@/utils/address-text-format'; +import { InvoicePaperTemplateProps } from '@bigcapital/pdf-templates'; export const mergePdfTemplateWithDefaultAttributes = ( brandingTemplate?: Record, @@ -18,7 +19,7 @@ export const mergePdfTemplateWithDefaultAttributes = ( export const transformInvoiceToPdfTemplate = ( invoice: ISaleInvoice -): Partial => { +): Partial => { return { dueDate: invoice.dueDateFormatted, dateIssue: invoice.invoiceDateFormatted, @@ -28,6 +29,11 @@ export const transformInvoiceToPdfTemplate = ( subtotal: invoice.subtotalFormatted, paymentMade: invoice.paymentAmountFormatted, dueAmount: invoice.dueAmountFormatted, + discount: invoice.discountAmountFormatted, + adjustment: invoice.adjustmentFormatted, + discountLabel: invoice.discountPercentageFormatted + ? `Discount [${invoice.discountPercentageFormatted}]` + : 'Discount', termsConditions: invoice.termsConditions, statement: invoice.invoiceMessage, @@ -37,13 +43,14 @@ export const transformInvoiceToPdfTemplate = ( description: entry.description, rate: entry.rateFormatted, quantity: entry.quantityFormatted, + discount: entry.discountFormatted, total: entry.totalFormatted, })), taxes: invoice.taxes.map((tax) => ({ label: tax.name, amount: tax.taxRateAmountFormatted, })), - + showLineDiscount: invoice.entries.some((entry) => entry.discountFormatted), customerAddress: contactAddressTextFormat(invoice.customer), }; }; diff --git a/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedMailState.tsx b/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedMailState.tsx new file mode 100644 index 000000000..b2910e4e0 --- /dev/null +++ b/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedMailState.tsx @@ -0,0 +1,52 @@ +import { PaymentReceiveMailOpts } from '@/interfaces'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { GetPaymentReceivedMailStateTransformer } from './GetPaymentReceivedMailStateTransformer'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { Inject, Service } from 'typedi'; +import { SendPaymentReceiveMailNotification } from './PaymentReceivedMailNotification'; + +@Service() +export class GetPaymentReceivedMailState { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private paymentReceivedMail: SendPaymentReceiveMailNotification; + + @Inject() + private transformer: TransformerInjectable; + + /** + * 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) + .withGraphFetched('customer') + .withGraphFetched('entries.invoice') + .withGraphFetched('pdfTemplate') + .throwIfNotFound(); + + const mailOptions = await this.paymentReceivedMail.getMailOptions( + tenantId, + paymentId + ); + const transformed = await this.transformer.transform( + tenantId, + paymentReceive, + new GetPaymentReceivedMailStateTransformer(), + { + mailOptions, + } + ); + return transformed; + }; +} diff --git a/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedMailStateTransformer.ts b/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedMailStateTransformer.ts new file mode 100644 index 000000000..20a53ff74 --- /dev/null +++ b/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedMailStateTransformer.ts @@ -0,0 +1,186 @@ +import { PaymentReceiveEntry } from '@/models'; +import { PaymentReceiveTransfromer } from './PaymentReceivedTransformer'; +import { PaymentReceivedEntryTransfromer } from './PaymentReceivedEntryTransformer'; + +export class GetPaymentReceivedMailStateTransformer extends PaymentReceiveTransfromer { + /** + * Exclude these attributes from user object. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Included attributes. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'paymentDate', + 'paymentDateFormatted', + + 'paymentAmount', + 'paymentAmountFormatted', + + 'total', + 'totalFormatted', + + 'subtotal', + 'subtotalFormatted', + + 'paymentNumber', + + 'entries', + + 'companyName', + 'companyLogoUri', + + 'primaryColor', + + 'customerName', + ]; + }; + + /** + * Retrieves the customer name of the payment. + * @returns {string} + */ + protected customerName = (payment) => { + return payment.customer.displayName; + }; + + /** + * Retrieves the company name. + * @returns {string} + */ + protected companyName = () => { + return this.context.organization.name; + }; + + /** + * Retrieves the company logo uri. + * @returns {string | null} + */ + protected companyLogoUri = (payment) => { + return payment.pdfTemplate?.companyLogoUri; + }; + + /** + * Retrieves the primary color. + * @returns {string} + */ + protected primaryColor = (payment) => { + return payment.pdfTemplate?.attributes?.primaryColor; + }; + + /** + * Retrieves the formatted payment date. + * @returns {string} + */ + protected paymentDateFormatted = (payment) => { + return this.formatDate(payment.paymentDate); + }; + + /** + * Retrieves the payment amount. + * @param payment + * @returns {number} + */ + protected total = (payment) => { + return this.formatNumber(payment.amount, { + money: false, + }); + }; + + /** + * Retrieves the formatted payment amount. + * @returns {string} + */ + protected totalFormatted = (payment) => { + return this.formatMoney(payment.amount); + }; + + /** + * Retrieves the payment amount. + * @param payment + * @returns {number} + */ + protected subtotal = (payment) => { + return this.formatNumber(payment.amount, { + money: false, + }); + }; + + /** + * Retrieves the formatted payment amount. + * @returns {string} + */ + protected subtotalFormatted = (payment) => { + return this.formatMoney(payment.amount); + }; + + /** + * Retrieves the payment number. + * @param payment + * @returns {string} + */ + protected paymentNumber = (payment) => { + return payment.paymentReceiveNo; + }; + + /** + * Retrieves the payment entries. + * @param {IPaymentReceived} payment + * @returns {IPaymentReceivedEntry[]} + */ + protected entries = (payment) => { + return this.item(payment.entries, new GetPaymentReceivedEntryMailState()); + }; + + /** + * Merges the mail options with the invoice object. + */ + public transform = (object: any) => { + return { + ...this.options.mailOptions, + ...object, + }; + }; +} + +export class GetPaymentReceivedEntryMailState extends PaymentReceivedEntryTransfromer { + /** + * Include these attributes to payment receive entry object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return ['paidAmount', 'invoiceNumber']; + }; + + /** + * Exclude these attributes from user object. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Retrieves the paid amount. + * @param entry + * @returns {string} + */ + public paidAmount = (entry) => { + return this.paymentAmountFormatted(entry); + }; + + /** + * Retrieves the invoice number. + * @param entry + * @returns {string} + */ + public invoiceNumber = (entry) => { + return entry.invoice.invoiceNo; + }; +} diff --git a/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedMailTemplate.ts b/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedMailTemplate.ts new file mode 100644 index 000000000..8e5f1cf5c --- /dev/null +++ b/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedMailTemplate.ts @@ -0,0 +1,75 @@ +import { Inject, Service } from 'typedi'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { GetPdfTemplate } from '@/services/PdfTemplate/GetPdfTemplate'; +import { GetPaymentReceived } from './GetPaymentReceived'; +import { GetPaymentReceivedMailTemplateAttrsTransformer } from './GetPaymentReceivedMailTemplateAttrsTransformer'; +import { + PaymentReceivedEmailTemplateProps, + renderPaymentReceivedEmailTemplate, +} from '@bigcapital/email-components'; + +@Service() +export class GetPaymentReceivedMailTemplate { + @Inject() + private getPaymentReceivedService: GetPaymentReceived; + + @Inject() + private getBrandingTemplate: GetPdfTemplate; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieves the mail template attributes of the given payment received. + * @param {number} tenantId - Tenant id. + * @param {number} paymentReceivedId - Payment received id. + * @returns {Promise} + */ + public async getMailTemplateAttributes( + tenantId: number, + paymentReceivedId: number + ): Promise { + const paymentReceived = + await this.getPaymentReceivedService.getPaymentReceive( + tenantId, + paymentReceivedId + ); + const brandingTemplate = await this.getBrandingTemplate.getPdfTemplate( + tenantId, + paymentReceived.pdfTemplateId + ); + const mailTemplateAttributes = await this.transformer.transform( + tenantId, + paymentReceived, + new GetPaymentReceivedMailTemplateAttrsTransformer(), + { + paymentReceived, + brandingTemplate, + } + ); + return mailTemplateAttributes; + } + + /** + * Retrieves the mail template html content. + * @param {number} tenantId + * @param {number} paymentReceivedId + * @param {Partial} overrideAttributes + * @returns + */ + public async getMailTemplate( + tenantId: number, + paymentReceivedId: number, + overrideAttributes?: Partial + ): Promise { + const mailTemplateAttributes = await this.getMailTemplateAttributes( + tenantId, + paymentReceivedId + ); + const mergedAttributes = { + ...mailTemplateAttributes, + ...overrideAttributes, + }; + return renderPaymentReceivedEmailTemplate(mergedAttributes); + } +} diff --git a/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedMailTemplateAttrsTransformer.ts b/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedMailTemplateAttrsTransformer.ts new file mode 100644 index 000000000..d4f4f8e02 --- /dev/null +++ b/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedMailTemplateAttrsTransformer.ts @@ -0,0 +1,149 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class GetPaymentReceivedMailTemplateAttrsTransformer extends Transformer { + /** + * Included attributes. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'companyLogoUri', + 'companyName', + 'primaryColor', + 'total', + 'totalLabel', + 'subtotal', + 'subtotalLabel', + 'paymentNumberLabel', + 'paymentNumber', + 'items', + ]; + }; + + /** + * Exclude all attributes. + * @returns {string[]} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Company logo uri. + * @returns {string} + */ + public companyLogoUri(): string { + return this.options.brandingTemplate?.companyLogoUri; + } + + /** + * Company name. + * @returns {string} + */ + public companyName(): string { + return this.context.organization.name; + } + + /** + * Primary color + * @returns {string} + */ + public primaryColor(): string { + return this.options?.brandingTemplate?.attributes?.primaryColor; + } + + /** + * Total. + * @returns {string} + */ + public total(): string { + return this.options.paymentReceived.formattedAmount; + } + + /** + * Total label. + * @returns {string} + */ + public totalLabel(): string { + return 'Total'; + } + + /** + * Subtotal. + * @returns {string} + */ + public subtotal(): string { + return this.options.paymentReceived.formattedAmount; + } + + /** + * Subtotal label. + * @returns {string} + */ + public subtotalLabel(): string { + return 'Subtotal'; + } + + /** + * Payment number label. + * @returns {string} + */ + public paymentNumberLabel(): string { + return 'Payment # {paymentNumber}'; + } + + /** + * Payment number. + * @returns {string} + */ + public paymentNumber(): string { + return this.options.paymentReceived.paymentReceiveNumber; + } + + /** + * Items. + * @returns + */ + public items() { + return this.item( + this.options.paymentReceived.entries, + new GetPaymentReceivedMailTemplateItemAttrsTransformer() + ); + } +} + +class GetPaymentReceivedMailTemplateItemAttrsTransformer extends Transformer { + /** + * Included attributes. + * @returns {Array} + */ + public includeAttributes = () => { + return ['label', 'total']; + }; + + /** + * Excluded attributes. + * @returns {string[]} + */ + public excludeAttributes = () => { + return ['*']; + }; + + /** + * + * @param entry + * @returns + */ + public label(entry) { + return entry.invoice.invoiceNo; + } + + /** + * + * @param entry + * @returns + */ + public total(entry) { + return entry.paymentAmountFormatted; + } +} diff --git a/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedPdf.ts b/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedPdf.ts index ed88b7ebf..36533961c 100644 --- a/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedPdf.ts +++ b/packages/server/src/services/Sales/PaymentReceived/GetPaymentReceivedPdf.ts @@ -1,13 +1,13 @@ import { Inject, Service } from 'typedi'; +import { renderPaymentReceivedPaperTemplateHtml } from '@bigcapital/pdf-templates'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; -import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; import { GetPaymentReceived } from './GetPaymentReceived'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { PaymentReceivedBrandingTemplate } from './PaymentReceivedBrandingTemplate'; import { transformPaymentReceivedToPdfTemplate } from './utils'; import { PaymentReceivedPdfTemplateAttributes } from '@/interfaces'; -import events from '@/subscribers/events'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; @Service() export default class GetPaymentReceivedPdf { @@ -17,9 +17,6 @@ export default class GetPaymentReceivedPdf { @Inject() private chromiumlyTenancy: ChromiumlyTenancy; - @Inject() - private templateInjectable: TemplateInjectable; - @Inject() private getPaymentService: GetPaymentReceived; @@ -29,6 +26,23 @@ export default class GetPaymentReceivedPdf { @Inject() private eventPublisher: EventPublisher; + /** + * Retrieves payment received html content. + * @param {number} tenantId + * @param {number} paymentReceivedId + * @returns {Promise} + */ + public async getPaymentReceivedHtml( + tenantId: number, + paymentReceivedId: number + ): Promise { + const brandingAttributes = await this.getPaymentBrandingAttributes( + tenantId, + paymentReceivedId + ); + return renderPaymentReceivedPaperTemplateHtml(brandingAttributes); + } + /** * Retrieve sale invoice pdf content. * @param {number} tenantId - @@ -39,15 +53,10 @@ export default class GetPaymentReceivedPdf { tenantId: number, paymentReceivedId: number ): Promise<[Buffer, string]> { - const brandingAttributes = await this.getPaymentBrandingAttributes( + const htmlContent = await this.getPaymentReceivedHtml( tenantId, paymentReceivedId ); - const htmlContent = await this.templateInjectable.render( - tenantId, - 'modules/payment-receive-standard', - brandingAttributes - ); const filename = await this.getPaymentReceivedFilename( tenantId, paymentReceivedId diff --git a/packages/server/src/services/Sales/PaymentReceived/PaymentReceivedApplication.ts b/packages/server/src/services/Sales/PaymentReceived/PaymentReceivedApplication.ts index f88e784dd..a2bac86fa 100644 --- a/packages/server/src/services/Sales/PaymentReceived/PaymentReceivedApplication.ts +++ b/packages/server/src/services/Sales/PaymentReceived/PaymentReceivedApplication.ts @@ -20,6 +20,7 @@ import { PaymentReceiveNotifyBySms } from './PaymentReceivedSmsNotify'; import GetPaymentReceivedPdf from './GetPaymentReceivedPdf'; import { SendPaymentReceiveMailNotification } from './PaymentReceivedMailNotification'; import { GetPaymentReceivedState } from './GetPaymentReceivedState'; +import { GetPaymentReceivedMailState } from './GetPaymentReceivedMailState'; @Service() export class PaymentReceivesApplication { @@ -53,6 +54,9 @@ export class PaymentReceivesApplication { @Inject() private getPaymentReceivedStateService: GetPaymentReceivedState; + @Inject() + private getPaymentReceivedMailStateService: GetPaymentReceivedMailState; + /** * Creates a new payment receive. * @param {number} tenantId @@ -204,12 +208,15 @@ export class PaymentReceivesApplication { /** * Retrieves the default mail options of the given payment transaction. - * @param {number} tenantId - * @param {number} paymentReceiveId + * @param {number} tenantId - Tenant id. + * @param {number} paymentReceiveId - Payment received id. * @returns {Promise} */ public getPaymentMailOptions(tenantId: number, paymentReceiveId: number) { - return this.paymentMailNotify.getMailOptions(tenantId, paymentReceiveId); + return this.getPaymentReceivedMailStateService.getMailOptions( + tenantId, + paymentReceiveId + ); } /** @@ -228,6 +235,22 @@ export class PaymentReceivesApplication { ); }; + /** + * Retrieves the given payment receive html document. + * @param {number} tenantId + * @param {number} paymentReceiveId + * @returns {Promise} + */ + public getPaymentReceivedHtml = ( + tenantId: number, + paymentReceiveId: number + ) => { + return this.getPaymentReceivePdfService.getPaymentReceivedHtml( + tenantId, + paymentReceiveId + ); + }; + /** * Retrieves the create/edit initial state of the payment received. * @param {number} tenantId - The ID of the tenant. diff --git a/packages/server/src/services/Sales/PaymentReceived/PaymentReceivedMailNotification.ts b/packages/server/src/services/Sales/PaymentReceived/PaymentReceivedMailNotification.ts index a02db9143..daaa43c8c 100644 --- a/packages/server/src/services/Sales/PaymentReceived/PaymentReceivedMailNotification.ts +++ b/packages/server/src/services/Sales/PaymentReceived/PaymentReceivedMailNotification.ts @@ -15,8 +15,9 @@ import { GetPaymentReceived } from './GetPaymentReceived'; import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; import { mergeAndValidateMailOptions } from '@/services/MailNotification/utils'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; -import events from '@/subscribers/events'; import { transformPaymentReceivedToMailDataArgs } from './utils'; +import { GetPaymentReceivedMailTemplate } from './GetPaymentReceivedMailTemplate'; +import events from '@/subscribers/events'; @Service() export class SendPaymentReceiveMailNotification { @@ -29,12 +30,15 @@ export class SendPaymentReceiveMailNotification { @Inject() private contactMailNotification: ContactMailNotification; - @Inject('agenda') - private agenda: any; - @Inject() private eventPublisher: EventPublisher; + @Inject() + private paymentMailTemplate: GetPaymentReceivedMailTemplate; + + @Inject('agenda') + private agenda: any; + /** * Sends the mail of the given payment receive. * @param {number} tenantId @@ -62,37 +66,6 @@ export class SendPaymentReceiveMailNotification { } 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 formatArgs = await this.textFormatter(tenantId, paymentId); - - const mailOptions = - await this.contactMailNotification.getDefaultMailOptions( - tenantId, - paymentReceive.customerId - ); - return { - ...mailOptions, - subject: DEFAULT_PAYMENT_MAIL_SUBJECT, - message: DEFAULT_PAYMENT_MAIL_CONTENT, - ...formatArgs, - }; - }; - /** * Retrieves the formatted text of the given sale invoice. * @param {number} tenantId - Tenant id. @@ -108,7 +81,82 @@ export class SendPaymentReceiveMailNotification { tenantId, invoiceId ); - return transformPaymentReceivedToMailDataArgs(payment); + const commonArgs = await this.contactMailNotification.getCommonFormatArgs( + tenantId + ); + const paymentArgs = transformPaymentReceivedToMailDataArgs(payment); + + return { + ...commonArgs, + ...paymentArgs, + }; + }; + + /** + * Retrieves the mail options of the given payment received. + * @param {number} tenantId - Tenant id. + * @param {number} paymentReceivedId - Payment received id. + * @param {string} defaultSubject - Default subject of the mail. + * @param {string} defaultContent - Default content of the mail. + * @returns + */ + public getMailOptions = async ( + tenantId: number, + paymentReceivedId: number, + defaultSubject: string = DEFAULT_PAYMENT_MAIL_SUBJECT, + defaultContent: string = DEFAULT_PAYMENT_MAIL_CONTENT + ): Promise => { + const { PaymentReceive } = this.tenancy.models(tenantId); + + const paymentReceived = await PaymentReceive.query().findById( + paymentReceivedId + ); + const formatArgs = await this.textFormatter(tenantId, paymentReceivedId); + + // Retrieves the default mail options. + const mailOptions = + await this.contactMailNotification.getDefaultMailOptions( + tenantId, + paymentReceived.customerId + ); + return { + ...mailOptions, + message: defaultContent, + subject: defaultSubject, + attachPdf: true, + formatArgs, + }; + }; + + /** + * Formats the mail options of the given payment receive. + * @param {number} tenantId + * @param {number} paymentReceiveId + * @param {PaymentReceiveMailOpts} mailOptions + * @returns {Promise} + */ + public formattedMailOptions = async ( + tenantId: number, + paymentReceiveId: number, + mailOptions: PaymentReceiveMailOpts + ): Promise => { + const formatterArgs = await this.textFormatter(tenantId, paymentReceiveId); + const formattedOptions = + await this.contactMailNotification.formatMailOptions( + tenantId, + mailOptions, + formatterArgs + ); + // Retrieves the mail template. + const message = await this.paymentMailTemplate.getMailTemplate( + tenantId, + paymentReceiveId, + { + message: formattedOptions.message, + preview: formattedOptions.message, + } + ); + return { ...formattedOptions, message }; }; /** @@ -136,10 +184,10 @@ export class SendPaymentReceiveMailNotification { messageDTO ); // Formats the message options. - return this.contactMailNotification.formatMailOptions( + return this.formattedMailOptions( tenantId, - parsedMessageOpts, - formatterArgs + paymentReceiveId, + parsedMessageOpts ); }; diff --git a/packages/server/src/services/Sales/PaymentReceived/constants.ts b/packages/server/src/services/Sales/PaymentReceived/constants.ts index ffbce4a9f..713d7218c 100644 --- a/packages/server/src/services/Sales/PaymentReceived/constants.ts +++ b/packages/server/src/services/Sales/PaymentReceived/constants.ts @@ -1,18 +1,15 @@ export const DEFAULT_PAYMENT_MAIL_SUBJECT = - 'Payment Received for {Customer Name} from {Company Name}'; -export const DEFAULT_PAYMENT_MAIL_CONTENT = ` -

Dear {Customer Name}

-

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

-

-Payment Date : {Payment Date}
-Amount : {Payment Amount}
-

+ ' Payment Confirmation from {Company Name} – Thank You!'; +export const DEFAULT_PAYMENT_MAIL_CONTENT = `Dear {Customer Name} -

-Regards
-{Company Name} -

-`; +Thank you for your payment. It was a pleasure doing business with you. We look forward to work together again! + +Payment Transaction: {Payment Number} +Payment Date : {Payment Date} +Amount : {Payment Amount} + +Regards, +{Company Name}`; export const ERRORS = { PAYMENT_RECEIVE_NO_EXISTS: 'PAYMENT_RECEIVE_NO_EXISTS', diff --git a/packages/server/src/services/Sales/Receipts/GetSaleReceiptMailState.ts b/packages/server/src/services/Sales/Receipts/GetSaleReceiptMailState.ts new file mode 100644 index 000000000..bc0e810c7 --- /dev/null +++ b/packages/server/src/services/Sales/Receipts/GetSaleReceiptMailState.ts @@ -0,0 +1,45 @@ +import { Inject, Service } from 'typedi'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { SaleReceiptMailNotification } from './SaleReceiptMailNotification'; +import { GetSaleReceiptMailStateTransformer } from './GetSaleReceiptMailStateTransformer'; + +@Service() +export class GetSaleReceiptMailState { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + @Inject() + private receiptMail: SaleReceiptMailNotification; + + /** + * Retrieves the sale receipt mail state of the given sale receipt. + * @param {number} tenantId + * @param {number} saleReceiptId + */ + public async getMailState(tenantId: number, saleReceiptId: number) { + const { SaleReceipt } = this.tenancy.models(tenantId); + + const saleReceipt = await SaleReceipt.query() + .findById(saleReceiptId) + .withGraphFetched('entries.item') + .withGraphFetched('customer') + .throwIfNotFound(); + + const mailOptions = await this.receiptMail.getMailOptions( + tenantId, + saleReceiptId + ); + return this.transformer.transform( + tenantId, + saleReceipt, + new GetSaleReceiptMailStateTransformer(), + { + mailOptions, + } + ); + } +} diff --git a/packages/server/src/services/Sales/Receipts/GetSaleReceiptMailStateTransformer.ts b/packages/server/src/services/Sales/Receipts/GetSaleReceiptMailStateTransformer.ts new file mode 100644 index 000000000..1f6126f52 --- /dev/null +++ b/packages/server/src/services/Sales/Receipts/GetSaleReceiptMailStateTransformer.ts @@ -0,0 +1,221 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; +import { ItemEntryTransformer } from '../Invoices/ItemEntryTransformer'; +import { DiscountType } from '@/interfaces'; +import { SaleReceiptTransformer } from './SaleReceiptTransformer'; + +export class GetSaleReceiptMailStateTransformer extends SaleReceiptTransformer { + /** + * Exclude these attributes from user object. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Included attributes. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'companyName', + 'companyLogoUri', + 'primaryColor', + 'customerName', + + 'total', + 'totalFormatted', + + 'discountAmount', + 'discountAmountFormatted', + 'discountPercentage', + 'discountPercentageFormatted', + 'discountLabel', + + 'adjustment', + 'adjustmentFormatted', + + 'subtotal', + 'subtotalFormatted', + + 'receiptDate', + 'receiptDateFormatted', + + 'closedAtDate', + 'closedAtDateFormatted', + + 'receiptNumber', + 'entries', + ]; + }; + + /** + * Retrieves the customer name of the invoice. + * @returns {string} + */ + protected customerName = (receipt) => { + return receipt.customer.displayName; + }; + + /** + * Retrieves the company name. + * @returns {string} + */ + protected companyName = () => { + return this.context.organization.name; + }; + + /** + * Retrieves the company logo uri. + * @returns {string | null} + */ + protected companyLogoUri = (receipt) => { + return receipt.pdfTemplate?.companyLogoUri; + }; + + /** + * Retrieves the primary color. + * @returns {string} + */ + protected primaryColor = (receipt) => { + return receipt.pdfTemplate?.attributes?.primaryColor; + }; + + /** + * Retrieves the total amount. + * @param receipt + * @returns + */ + protected total = (receipt) => { + return receipt.total; + }; + + /** + * Retrieves the formatted total amount. + * @param receipt + * @returns {string} + */ + protected totalFormatted = (receipt) => { + return this.formatMoney(receipt.total, { + currencyCode: receipt.currencyCode, + }); + }; + + /** + * Retrieves the discount label of the estimate. + * @param estimate + * @returns {string} + */ + protected discountLabel(receipt) { + return receipt.discountType === DiscountType.Percentage + ? `Discount [${receipt.discountPercentageFormatted}]` + : 'Discount'; + } + + /** + * Retrieves the subtotal of the receipt. + * @param receipt + * @returns + */ + protected subtotal = (receipt) => { + return receipt.subtotal; + }; + + /** + * Retrieves the formatted subtotal of the receipt. + * @param receipt + * @returns + */ + protected subtotalFormatted = (receipt) => { + return this.formatMoney(receipt.subtotal, { + currencyCode: receipt.currencyCode, + }); + }; + + /** + * Retrieves the receipt date. + * @param receipt + * @returns + */ + protected receiptDate = (receipt): string => { + return receipt.receiptDate; + }; + + /** + * Retrieves the formatted receipt date. + * @param {ISaleReceipt} invoice + * @returns {string} + */ + protected receiptDateFormatted = (receipt): string => { + return this.formatDate(receipt.receiptDate); + }; + + /** + * + * @param receipt + * @returns + */ + protected closedAtDate = (receipt): string => { + return receipt.closedAt; + }; + + /** + * Retrieve formatted estimate closed at date. + * @param {ISaleReceipt} invoice + * @returns {String} + */ + protected closedAtDateFormatted = (receipt): string => { + return this.formatDate(receipt.closedAt); + }; + + /** + * + * @param invoice + * @returns + */ + protected entries = (receipt) => { + return this.item( + receipt.entries, + new GetSaleReceiptEntryMailStateTransformer(), + { + currencyCode: receipt.currencyCode, + } + ); + }; + + /** + * Merges the mail options with the invoice object. + */ + public transform = (object: any) => { + return { + ...this.options.mailOptions, + ...object, + }; + }; +} + +class GetSaleReceiptEntryMailStateTransformer extends ItemEntryTransformer { + /** + * Exclude these attributes from user object. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + public name = (entry) => { + return entry.item.name; + }; + + public includeAttributes = (): string[] => { + return [ + 'name', + 'quantity', + 'quantityFormatted', + 'rate', + 'rateFormatted', + 'total', + 'totalFormatted', + ]; + }; +} diff --git a/packages/server/src/services/Sales/Receipts/GetSaleReceiptMailTemplate.ts b/packages/server/src/services/Sales/Receipts/GetSaleReceiptMailTemplate.ts new file mode 100644 index 000000000..2a46e27c9 --- /dev/null +++ b/packages/server/src/services/Sales/Receipts/GetSaleReceiptMailTemplate.ts @@ -0,0 +1,75 @@ +import { + ReceiptEmailTemplateProps, + renderReceiptEmailTemplate, +} from '@bigcapital/email-components'; +import { Inject, Service } from 'typedi'; +import { GetSaleReceipt } from './GetSaleReceipt'; +import { GetPdfTemplate } from '@/services/PdfTemplate/GetPdfTemplate'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { GetSaleReceiptMailTemplateAttributesTransformer } from './GetSaleReceiptMailTemplateAttributesTransformer'; + +@Service() +export class GetSaleReceiptMailTemplate { + @Inject() + private getReceiptService: GetSaleReceipt; + + @Inject() + private transformer: TransformerInjectable; + + @Inject() + private getBrandingTemplate: GetPdfTemplate; + + /** + * Retrieves the mail template attributes of the given estimate. + * Estimate template attributes are composed of the estimate and branding template attributes. + * @param {number} tenantId + * @param {number} receiptId - Receipt id. + * @returns {Promise} + */ + public async getMailTemplateAttributes( + tenantId: number, + receiptId: number + ): Promise { + const receipt = await this.getReceiptService.getSaleReceipt( + tenantId, + receiptId + ); + const brandingTemplate = await this.getBrandingTemplate.getPdfTemplate( + tenantId, + receipt.pdfTemplateId + ); + const mailTemplateAttributes = await this.transformer.transform( + tenantId, + receipt, + new GetSaleReceiptMailTemplateAttributesTransformer(), + { + receipt, + brandingTemplate, + } + ); + return mailTemplateAttributes; + } + + /** + * Retrieves the mail template html content. + * @param {number} tenantId + * @param {number} estimateId + * @param overrideAttributes + * @returns + */ + public async getMailTemplate( + tenantId: number, + estimateId: number, + overrideAttributes?: Partial + ): Promise { + const attributes = await this.getMailTemplateAttributes( + tenantId, + estimateId + ); + const mergedAttributes = { + ...attributes, + ...overrideAttributes, + }; + return renderReceiptEmailTemplate(mergedAttributes); + } +} diff --git a/packages/server/src/services/Sales/Receipts/GetSaleReceiptMailTemplateAttributesTransformer.ts b/packages/server/src/services/Sales/Receipts/GetSaleReceiptMailTemplateAttributesTransformer.ts new file mode 100644 index 000000000..43a212425 --- /dev/null +++ b/packages/server/src/services/Sales/Receipts/GetSaleReceiptMailTemplateAttributesTransformer.ts @@ -0,0 +1,201 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class GetSaleReceiptMailTemplateAttributesTransformer extends Transformer { + public includeAttributes = (): string[] => { + return [ + 'companyLogoUri', + 'companyName', + + 'primaryColor', + + 'receiptAmount', + 'receiptMessage', + + 'date', + 'dateLabel', + + 'receiptNumber', + 'receiptNumberLabel', + + 'total', + 'totalLabel', + + 'discount', + 'discountLabel', + + 'adjustment', + 'adjustmentLabel', + + 'subtotal', + 'subtotalLabel', + + 'paidAmount', + 'paidAmountLabel', + + 'items', + ]; + }; + + /** + * Exclude all attributes. + * @returns {string[]} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Company logo uri. + * @returns {string} + */ + public companyLogoUri(): string { + return this.options.brandingTemplate?.companyLogoUri; + } + + /** + * Company name. + * @returns {string} + */ + public companyName(): string { + return this.context.organization.name; + } + + /** + * Primary color + * @returns {string} + */ + public primaryColor(): string { + return this.options?.brandingTemplate?.attributes?.primaryColor; + } + + /** + * Receipt number. + * @returns {string} + */ + public receiptNumber(): string { + return this.options.receipt.receiptNumber; + } + + /** + * Receipt number label. + * @returns {string} + */ + public receiptNumberLabel(): string { + return 'Receipt # {receiptNumber}'; + } + + /** + * Date. + * @returns {string} + */ + public date(): string { + return this.options.receipt.date; + } + + /** + * Date label. + * @returns {string} + */ + public dateLabel(): string { + return 'Date'; + } + + /** + * Receipt total. + */ + public total(): string { + return this.options.receipt.totalFormatted; + } + + /** + * Receipt total label. + * @returns {string} + */ + public totalLabel(): string { + return 'Total'; + } + + /** + * Receipt discount. + * @returns {string} + */ + public discount(): string { + return this.options.receipt?.discountAmountFormatted; + } + + /** + * Receipt discount label. + * @returns {string} + */ + public discountLabel(): string { + return 'Discount'; + } + + /** + * Receipt adjustment. + * @returns {string} + */ + public adjustment(): string { + return this.options.receipt?.adjustmentFormatted; + } + + /** + * Receipt adjustment label. + * @returns {string} + */ + public adjustmentLabel(): string { + return 'Adjustment'; + } + + /** + * Receipt subtotal. + * @returns {string} + */ + public subtotal(): string { + return this.options.receipt.subtotalFormatted; + } + + /** + * Receipt subtotal label. + * @returns {string} + */ + public subtotalLabel(): string { + return 'Subtotal'; + } + + /** + * Receipt mail items attributes. + */ + public items(): any[] { + return this.item( + this.options.receipt.entries, + new GetSaleReceiptMailTemplateEntryAttributesTransformer() + ); + } +} + +class GetSaleReceiptMailTemplateEntryAttributesTransformer extends Transformer { + public includeAttributes = (): string[] => { + return ['label', 'quantity', 'rate', 'total']; + }; + + public excludeAttributes = (): string[] => { + return ['*']; + }; + + public label(entry): string { + return entry?.item?.name; + } + + public quantity(entry): string { + return entry?.quantity; + } + + public rate(entry): string { + return entry?.rateFormatted; + } + + public total(entry): string { + return entry?.totalFormatted; + } +} diff --git a/packages/server/src/services/Sales/Receipts/GetSaleReceiptState.ts b/packages/server/src/services/Sales/Receipts/GetSaleReceiptState.ts index 339750b9f..cde274978 100644 --- a/packages/server/src/services/Sales/Receipts/GetSaleReceiptState.ts +++ b/packages/server/src/services/Sales/Receipts/GetSaleReceiptState.ts @@ -8,7 +8,7 @@ export class GetSaleReceiptState { private tenancy: HasTenancyService; /** - * Retireves the sale receipt state. + * Retrieves the sale receipt state. * @param {Number} tenantId - * @return {Promise} */ diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts index b68f195f0..5fbcd78cc 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts @@ -18,6 +18,7 @@ import { SaleReceiptsPdf } from './SaleReceiptsPdfService'; import { SaleReceiptNotifyBySms } from './SaleReceiptNotifyBySms'; import { SaleReceiptMailNotification } from './SaleReceiptMailNotification'; import { GetSaleReceiptState } from './GetSaleReceiptState'; +import { GetSaleReceiptMailState } from './GetSaleReceiptMailState'; @Service() export class SaleReceiptApplication { @@ -51,6 +52,9 @@ export class SaleReceiptApplication { @Inject() private getSaleReceiptStateService: GetSaleReceiptState; + @Inject() + private getSaleReceiptMailStateService: GetSaleReceiptMailState; + /** * Creates a new sale receipt with associated entries. * @param {number} tenantId @@ -152,6 +156,19 @@ export class SaleReceiptApplication { ); } + /** + * Retrieves the given sale receipt html. + * @param {number} tenantId + * @param {number} saleReceiptId + * @returns {Promise} + */ + public getSaleReceiptHtml(tenantId: number, saleReceiptId: number) { + return this.getSaleReceiptPdfService.saleReceiptHtml( + tenantId, + saleReceiptId + ); + } + /** * Notify receipt customer by SMS of the given sale receipt. * @param {number} tenantId @@ -221,4 +238,20 @@ export class SaleReceiptApplication { public getSaleReceiptState(tenantId: number): Promise { return this.getSaleReceiptStateService.getSaleReceiptState(tenantId); } + + /** + * Retrieves the mail state of the given sale receipt. + * @param {number} tenantId + * @param {number} saleReceiptId + * @returns + */ + public getSaleReceiptMailState( + tenantId: number, + saleReceiptId: number + ): Promise { + return this.getSaleReceiptMailStateService.getMailState( + tenantId, + saleReceiptId + ); + } } diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptGLEntries.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptGLEntries.ts index d354141e9..3cb4d9d0e 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptGLEntries.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptGLEntries.ts @@ -31,13 +31,27 @@ export class SaleReceiptGLEntries { trx?: Knex.Transaction ): Promise => { const { SaleReceipt } = this.tenancy.models(tenantId); + const { accountRepository } = this.tenancy.repositories(tenantId); const saleReceipt = await SaleReceipt.query(trx) .findById(saleReceiptId) .withGraphFetched('entries.item'); + // Find or create the discount expense account. + const discountAccount = await accountRepository.findOrCreateDiscountAccount( + {}, + trx + ); + // Find or create the other charges account. + const otherChargesAccount = + await accountRepository.findOrCreateOtherChargesAccount({}, trx); + // Retrieve the income entries ledger. - const incomeLedger = this.getIncomeEntriesLedger(saleReceipt); + const incomeLedger = this.getIncomeEntriesLedger( + saleReceipt, + discountAccount.id, + otherChargesAccount.id + ); // Commits the ledger entries to the storage. await this.ledgerStorage.commit(tenantId, incomeLedger, trx); @@ -87,8 +101,16 @@ export class SaleReceiptGLEntries { * @param {ISaleReceipt} saleReceipt * @returns {Ledger} */ - private getIncomeEntriesLedger = (saleReceipt: ISaleReceipt): Ledger => { - const entries = this.getIncomeGLEntries(saleReceipt); + private getIncomeEntriesLedger = ( + saleReceipt: ISaleReceipt, + discountAccountId: number, + otherChargesAccountId: number + ): Ledger => { + const entries = this.getIncomeGLEntries( + saleReceipt, + discountAccountId, + otherChargesAccountId + ); return new Ledger(entries); }; @@ -121,10 +143,10 @@ export class SaleReceiptGLEntries { }; /** - * Retrieve receipt income item GL entry. - * @param {ISaleReceipt} saleReceipt - - * @param {IItemEntry} entry - - * @param {number} index - + * Retrieve receipt income item G/L entry. + * @param {ISaleReceipt} saleReceipt - + * @param {IItemEntry} entry - + * @param {number} index - * @returns {ILedgerEntry} */ private getReceiptIncomeItemEntry = R.curry( @@ -134,11 +156,11 @@ export class SaleReceiptGLEntries { index: number ): ILedgerEntry => { const commonEntry = this.getIncomeGLCommonEntry(saleReceipt); - const itemIncome = entry.amount * saleReceipt.exchangeRate; + const totalLocal = entry.totalExcludingTax * saleReceipt.exchangeRate; return { ...commonEntry, - credit: itemIncome, + credit: totalLocal, accountId: entry.item.sellAccountId, note: entry.description, index: index + 2, @@ -161,24 +183,76 @@ export class SaleReceiptGLEntries { return { ...commonEntry, - debit: saleReceipt.localAmount, + debit: saleReceipt.totalLocal, accountId: saleReceipt.depositAccountId, index: 1, accountNormal: AccountNormal.DEBIT, }; }; + /** + * Retrieves the discount GL entry. + * @param {ISaleReceipt} saleReceipt + * @param {number} discountAccountId + * @returns {ILedgerEntry} + */ + private getDiscountEntry = ( + saleReceipt: ISaleReceipt, + discountAccountId: number + ): ILedgerEntry => { + const commonEntry = this.getIncomeGLCommonEntry(saleReceipt); + + return { + ...commonEntry, + debit: saleReceipt.discountAmountLocal, + accountId: discountAccountId, + index: 1, + accountNormal: AccountNormal.CREDIT, + }; + }; + + /** + * Retrieves the adjustment GL entry. + * @param {ISaleReceipt} saleReceipt + * @param {number} adjustmentAccountId + * @returns {ILedgerEntry} + */ + private getAdjustmentEntry = ( + saleReceipt: ISaleReceipt, + adjustmentAccountId: number + ): ILedgerEntry => { + const commonEntry = this.getIncomeGLCommonEntry(saleReceipt); + const adjustmentAmount = Math.abs(saleReceipt.adjustmentLocal); + + return { + ...commonEntry, + debit: saleReceipt.adjustmentLocal < 0 ? adjustmentAmount : 0, + credit: saleReceipt.adjustmentLocal > 0 ? adjustmentAmount : 0, + accountId: adjustmentAccountId, + accountNormal: AccountNormal.CREDIT, + index: 1, + }; + }; + /** * Retrieves the income GL entries. * @param {ISaleReceipt} saleReceipt - * @returns {ILedgerEntry[]} */ - private getIncomeGLEntries = (saleReceipt: ISaleReceipt): ILedgerEntry[] => { + private getIncomeGLEntries = ( + saleReceipt: ISaleReceipt, + discountAccountId: number, + otherChargesAccountId: number + ): ILedgerEntry[] => { const getItemEntry = this.getReceiptIncomeItemEntry(saleReceipt); const creditEntries = saleReceipt.entries.map(getItemEntry); const depositEntry = this.getReceiptDepositEntry(saleReceipt); - - return [depositEntry, ...creditEntries]; + const discountEntry = this.getDiscountEntry(saleReceipt, discountAccountId); + const adjustmentEntry = this.getAdjustmentEntry( + saleReceipt, + otherChargesAccountId + ); + return [depositEntry, ...creditEntries, discountEntry, adjustmentEntry]; }; } diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts index 8fc1478d7..d9d6d7f15 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts @@ -17,6 +17,7 @@ import { mergeAndValidateMailOptions } from '@/services/MailNotification/utils'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import events from '@/subscribers/events'; import { transformReceiptToMailDataArgs } from './utils'; +import { GetSaleReceiptMailTemplate } from './GetSaleReceiptMailTemplate'; @Service() export class SaleReceiptMailNotification { @@ -32,6 +33,9 @@ export class SaleReceiptMailNotification { @Inject() private contactMailNotification: ContactMailNotification; + @Inject() + private getReceiptMailTemplate: GetSaleReceiptMailTemplate; + @Inject() private eventPublisher: EventPublisher; @@ -111,7 +115,13 @@ export class SaleReceiptMailNotification { tenantId, receiptId ); - return transformReceiptToMailDataArgs(receipt); + const commonArgs = await this.contactMailNotification.getCommonFormatArgs( + tenantId + ); + return { + ...commonArgs, + ...transformReceiptToMailDataArgs(receipt), + }; }; /** @@ -133,7 +143,15 @@ export class SaleReceiptMailNotification { mailOptions, formatterArgs )) as SaleReceiptMailOpts; - return formattedOptions; + + const message = await this.getReceiptMailTemplate.getMailTemplate( + tenantId, + receiptId, + { + message: formattedOptions.message, + } + ); + return { ...formattedOptions, message }; } /** diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptTransformer.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptTransformer.ts index 90d3631af..eb833b344 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptTransformer.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptTransformer.ts @@ -13,11 +13,24 @@ export class SaleReceiptTransformer extends Transformer { */ public includeAttributes = (): string[] => { return [ - 'formattedSubtotal', + 'discountAmountFormatted', + 'discountPercentageFormatted', + 'discountAmountLocalFormatted', + + 'subtotalFormatted', + 'subtotalLocalFormatted', + + 'totalFormatted', + 'totalLocalFormatted', + + 'adjustmentFormatted', + 'adjustmentLocalFormatted', + 'formattedAmount', 'formattedReceiptDate', 'formattedClosedAtDate', 'formattedCreatedAt', + 'paidFormatted', 'entries', 'attachments', ]; @@ -43,7 +56,7 @@ export class SaleReceiptTransformer extends Transformer { /** * Retrieve formatted receipt created at date. - * @param receipt + * @param receipt * @returns {string} */ protected formattedCreatedAt = (receipt: ISaleReceipt): string => { @@ -55,8 +68,41 @@ export class SaleReceiptTransformer extends Transformer { * @param {ISaleReceipt} receipt * @returns {string} */ - protected formattedSubtotal = (receipt: ISaleReceipt): string => { - return formatNumber(receipt.amount, { money: false }); + protected subtotalFormatted = (receipt: ISaleReceipt): string => { + return formatNumber(receipt.subtotal, { + currencyCode: receipt.currencyCode, + }); + }; + + /** + * Retrieves the estimate formatted subtotal in local currency. + * @param {ISaleReceipt} receipt + * @returns {string} + */ + protected subtotalLocalFormatted = (receipt: ISaleReceipt): string => { + return formatNumber(receipt.subtotalLocal, { + currencyCode: receipt.currencyCode, + }); + }; + + /** + * Retrieves the receipt formatted total. + * @param receipt + * @returns {string} + */ + protected totalFormatted = (receipt: ISaleReceipt): string => { + return formatNumber(receipt.total, { currencyCode: receipt.currencyCode }); + }; + + /** + * Retrieves the receipt formatted total in local currency. + * @param receipt + * @returns {string} + */ + protected totalLocalFormatted = (receipt: ISaleReceipt): string => { + return formatNumber(receipt.totalLocal, { + currencyCode: receipt.currencyCode, + }); }; /** @@ -64,12 +110,57 @@ export class SaleReceiptTransformer extends Transformer { * @param {ISaleReceipt} estimate * @returns {string} */ - protected formattedAmount = (receipt: ISaleReceipt): string => { + protected amountFormatted = (receipt: ISaleReceipt): string => { return formatNumber(receipt.amount, { currencyCode: receipt.currencyCode, }); }; + /** + * Retrieves formatted discount amount. + * @param receipt + * @returns {string} + */ + protected discountAmountFormatted = (receipt: ISaleReceipt): string => { + return formatNumber(receipt.discountAmount, { + currencyCode: receipt.currencyCode, + excerptZero: true, + }); + }; + + /** + * Retrieves formatted discount percentage. + * @param receipt + * @returns {string} + */ + protected discountPercentageFormatted = (receipt: ISaleReceipt): string => { + return receipt.discountPercentage ? `${receipt.discountPercentage}%` : ''; + }; + + /** + * Retrieves formatted paid amount. + * @param receipt + * @returns {string} + */ + protected paidFormatted = (receipt: ISaleReceipt): string => { + return formatNumber(receipt.paid, { + currencyCode: receipt.currencyCode, + excerptZero: true, + }); + }; + + /** + * Retrieves formatted adjustment amount. + * @param receipt + * @returns {string} + */ + protected adjustmentFormatted = (receipt: ISaleReceipt): string => { + return this.formatMoney(receipt.adjustment, { + currencyCode: receipt.currencyCode, + excerptZero: true, + }); + }; + /** * Retrieves the entries of the credit note. * @param {ISaleReceipt} credit diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptsPdfService.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptsPdfService.ts index 9c9c6132b..2aab47659 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptsPdfService.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptsPdfService.ts @@ -1,11 +1,13 @@ import { Inject, Service } from 'typedi'; -import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; +import { + renderReceiptPaperTemplateHtml, + ReceiptPaperTemplateProps, +} from '@bigcapital/pdf-templates'; import { GetSaleReceipt } from './GetSaleReceipt'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { SaleReceiptBrandingTemplate } from './SaleReceiptBrandingTemplate'; import { transformReceiptToBrandingTemplateAttributes } from './utils'; -import { ISaleReceiptBrandingTemplateAttributes } from '@/interfaces'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import events from '@/subscribers/events'; @@ -17,9 +19,6 @@ export class SaleReceiptsPdf { @Inject() private chromiumlyTenancy: ChromiumlyTenancy; - @Inject() - private templateInjectable: TemplateInjectable; - @Inject() private getSaleReceiptService: GetSaleReceipt; @@ -29,6 +28,19 @@ export class SaleReceiptsPdf { @Inject() private eventPublisher: EventPublisher; + /** + * Retrieves sale receipt html content. + * @param {number} tennatId + * @param {number} saleReceiptId + */ + public async saleReceiptHtml(tennatId: number, saleReceiptId: number) { + const brandingAttributes = await this.getReceiptBrandingAttributes( + tennatId, + saleReceiptId + ); + return renderReceiptPaperTemplateHtml(brandingAttributes); + } + /** * Retrieves sale invoice pdf content. * @param {number} tenantId - @@ -41,16 +53,9 @@ export class SaleReceiptsPdf { ): Promise<[Buffer, string]> { const filename = await this.getSaleReceiptFilename(tenantId, saleReceiptId); - const brandingAttributes = await this.getReceiptBrandingAttributes( - tenantId, - saleReceiptId - ); // Converts the receipt template to html content. - const htmlContent = await this.templateInjectable.render( - tenantId, - 'modules/receipt-regular', - brandingAttributes - ); + const htmlContent = await this.saleReceiptHtml(tenantId, saleReceiptId); + // Renders the html content to pdf document. const content = await this.chromiumlyTenancy.convertHtmlContent( tenantId, @@ -87,12 +92,12 @@ export class SaleReceiptsPdf { * Retrieves receipt branding attributes. * @param {number} tenantId * @param {number} receiptId - * @returns {Promise} + * @returns {Promise} */ public async getReceiptBrandingAttributes( tenantId: number, receiptId: number - ): Promise { + ): Promise { const { PdfTemplate } = this.tenancy.models(tenantId); const saleReceipt = await this.getSaleReceiptService.getSaleReceipt( diff --git a/packages/server/src/services/Sales/Receipts/constants.ts b/packages/server/src/services/Sales/Receipts/constants.ts index b1a1c3898..f8e821edb 100644 --- a/packages/server/src/services/Sales/Receipts/constants.ts +++ b/packages/server/src/services/Sales/Receipts/constants.ts @@ -1,18 +1,17 @@ export const DEFAULT_RECEIPT_MAIL_SUBJECT = 'Receipt {Receipt Number} from {Company Name}'; -export const DEFAULT_RECEIPT_MAIL_CONTENT = ` -

Dear {Customer Name}

-

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

-

-Receipt #{Receipt Number}
-Amount : {Receipt Amount}
-

+export const DEFAULT_RECEIPT_MAIL_CONTENT = `Hi {Customer Name}, -

-Regards
-{Company Name} -

-`; +Here's receipt # {Receipt Number} for Receipt {Receipt Amount} + +The receipt paid on {Receipt Date}, and the total amount paid is {Receipt Amount}. + +Please find your sale receipt attached to this email for your reference + +If you have any questions, please let us know. + +Thanks, +{Company Name}`; export const ERRORS = { SALE_RECEIPT_NOT_FOUND: 'SALE_RECEIPT_NOT_FOUND', diff --git a/packages/server/src/services/Sales/Receipts/utils.ts b/packages/server/src/services/Sales/Receipts/utils.ts index b075aa637..f29c0189a 100644 --- a/packages/server/src/services/Sales/Receipts/utils.ts +++ b/packages/server/src/services/Sales/Receipts/utils.ts @@ -1,24 +1,30 @@ -import { - ISaleReceipt, - ISaleReceiptBrandingTemplateAttributes, -} from '@/interfaces'; import { contactAddressTextFormat } from '@/utils/address-text-format'; +import { ReceiptPaperTemplateProps } from '@bigcapital/pdf-templates'; export const transformReceiptToBrandingTemplateAttributes = ( - saleReceipt: ISaleReceipt -): Partial => { + saleReceipt +): Partial => { return { - total: saleReceipt.formattedAmount, - subtotal: saleReceipt.formattedSubtotal, + total: saleReceipt.totalFormatted, + subtotal: saleReceipt.subtotalFormatted, lines: saleReceipt.entries?.map((entry) => ({ item: entry.item.name, description: entry.description, rate: entry.rateFormatted, quantity: entry.quantityFormatted, + discount: entry.discountFormatted, total: entry.totalFormatted, })), receiptNumber: saleReceipt.receiptNumber, receiptDate: saleReceipt.formattedReceiptDate, + discount: saleReceipt.discountAmountFormatted, + discountLabel: saleReceipt.discountPercentageFormatted + ? `Discount [${saleReceipt.discountPercentageFormatted}]` + : 'Discount', + showLineDiscount: saleReceipt.entries.some( + (entry) => entry.discountFormatted + ), + adjustment: saleReceipt.adjustmentFormatted, customerAddress: contactAddressTextFormat(saleReceipt.customer), }; }; diff --git a/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts b/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts index 5eaa7b980..2f4adb0ce 100644 --- a/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts +++ b/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts @@ -2,7 +2,7 @@ import { Inject, Service } from 'typedi'; import { keyBy, sumBy } from 'lodash'; import { ItemEntry } from '@/models'; import HasTenancyService from '../Tenancy/TenancyService'; -import { IItem, IItemEntry, IItemEntryDTO } from '@/interfaces'; +import { IItemEntry } from '@/interfaces'; @Service() export class ItemEntriesTaxTransactions { diff --git a/packages/server/src/utils/index.ts b/packages/server/src/utils/index.ts index aaf236eb5..1e9d48eac 100644 --- a/packages/server/src/utils/index.ts +++ b/packages/server/src/utils/index.ts @@ -413,7 +413,10 @@ export const formatSmsMessage = (message: string, args) => { const variable = `{${key}}`; const value = _.defaultTo(args[key], ''); - formattedMessage = formattedMessage.replace(variable, value); + formattedMessage = formattedMessage.replace( + new RegExp(variable, 'g'), + value + ); }); return formattedMessage; }; diff --git a/packages/webapp/package.json b/packages/webapp/package.json index c3a2edb6b..bbff7cba9 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -5,6 +5,7 @@ "dependencies": { "@bigcapital/utils": "*", "@bigcapital/pdf-templates": "*", + "@bigcapital/email-components": "*", "@blueprintjs-formik/core": "^0.3.7", "@blueprintjs-formik/datetime": "^0.3.7", "@blueprintjs-formik/select": "^0.3.5", diff --git a/packages/webapp/src/components/DataTableCells/NumericInputCell.tsx b/packages/webapp/src/components/DataTableCells/NumericInputCell.tsx index 71e2d637d..b3432267c 100644 --- a/packages/webapp/src/components/DataTableCells/NumericInputCell.tsx +++ b/packages/webapp/src/components/DataTableCells/NumericInputCell.tsx @@ -1,8 +1,6 @@ -// @ts-nocheck import React, { useState, useEffect } from 'react'; import { FormGroup, NumericInput, Intent } from '@blueprintjs/core'; import classNames from 'classnames'; - import { CellType } from '@/constants'; import { CLASSES } from '@/constants/classes'; @@ -12,37 +10,44 @@ import { CLASSES } from '@/constants/classes'; export default function NumericInputCell({ row: { index }, column: { id }, - cell: { value: initialValue }, + cell: { value: controlledInputValue }, payload, -}) { - const [value, setValue] = useState(initialValue); +}: any) { + const [valueAsNumber, setValueAsNumber] = useState( + controlledInputValue || null, + ); + const handleInputValueChange = ( + valueAsNumber: number, + valueAsString: string, + ) => { + setValueAsNumber(valueAsNumber); + }; + const handleInputBlur = () => { + payload.updateData(index, id, valueAsNumber); + }; - const handleValueChange = (newValue) => { - setValue(newValue); - }; - const onBlur = () => { - payload.updateData(index, id, value); - }; useEffect(() => { - setValue(initialValue); - }, [initialValue]); + setValueAsNumber(controlledInputValue); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controlledInputValue]); const error = payload.errors?.[index]?.[id]; return ( ); } -NumericInputCell.cellType = CellType.Field; \ No newline at end of file +NumericInputCell.cellType = CellType.Field; diff --git a/packages/webapp/src/components/Datatable/DataTable.tsx b/packages/webapp/src/components/Datatable/DataTable.tsx index 4709f7021..77ce28704 100644 --- a/packages/webapp/src/components/Datatable/DataTable.tsx +++ b/packages/webapp/src/components/Datatable/DataTable.tsx @@ -62,6 +62,9 @@ export function DataTable(props) { initialPageIndex = 0, initialPageSize = 20, + // Hidden columns. + initialHiddenColumns = [], + updateDebounceTime = 200, selectionColumnWidth = 42, @@ -115,6 +118,7 @@ export function DataTable(props) { columnResizing: { columnWidths: initialColumnsWidths || {}, }, + hiddenColumns: initialHiddenColumns, }, manualPagination, pageCount: controlledPageCount, diff --git a/packages/webapp/src/components/DialogsContainer.tsx b/packages/webapp/src/components/DialogsContainer.tsx index 3533cc6bc..a97200483 100644 --- a/packages/webapp/src/components/DialogsContainer.tsx +++ b/packages/webapp/src/components/DialogsContainer.tsx @@ -11,7 +11,6 @@ import QuickPaymentMadeFormDialog from '@/containers/Dialogs/QuickPaymentMadeFor import AllocateLandedCostDialog from '@/containers/Dialogs/AllocateLandedCostDialog'; import InvoicePdfPreviewDialog from '@/containers/Dialogs/InvoicePdfPreviewDialog'; import EstimatePdfPreviewDialog from '@/containers/Dialogs/EstimatePdfPreviewDialog'; -import ReceiptPdfPreviewDialog from '@/containers/Dialogs/ReceiptPdfPreviewDialog'; import MoneyInDialog from '@/containers/CashFlow/MoneyInDialog'; import MoneyOutDialog from '@/containers/CashFlow/MoneyOutDialog'; import BadDebtDialog from '@/containers/Dialogs/BadDebtDialog'; @@ -45,9 +44,6 @@ import ProjectBillableEntriesFormDialog from '@/containers/Projects/containers/P import TaxRateFormDialog from '@/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialog'; import { DialogsName } from '@/constants/dialogs'; import InvoiceExchangeRateChangeDialog from '@/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog'; -import EstimateMailDialog from '@/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog'; -import ReceiptMailDialog from '@/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog'; -import PaymentMailDialog from '@/containers/Sales/PaymentsReceived/PaymentMailDialog/PaymentMailDialog'; import { ExportDialog } from '@/containers/Dialogs/ExportDialog'; import { RuleFormDialog } from '@/containers/Banking/Rules/RuleFormDialog/RuleFormDialog'; import { DisconnectBankAccountDialog } from '@/containers/CashFlow/AccountTransactions/dialogs/DisconnectBankAccountDialog/DisconnectBankAccountDialog'; @@ -81,7 +77,6 @@ export default function DialogsContainer() { /> - @@ -143,9 +138,6 @@ export default function DialogsContainer() { - - - + + + ); } diff --git a/packages/webapp/src/components/PageForm/PageFormBigNumber.tsx b/packages/webapp/src/components/PageForm/PageFormBigNumber.tsx index 5508b8fdb..914cc95a0 100644 --- a/packages/webapp/src/components/PageForm/PageFormBigNumber.tsx +++ b/packages/webapp/src/components/PageForm/PageFormBigNumber.tsx @@ -2,18 +2,22 @@ import React from 'react'; import classNames from 'classnames'; import { CLASSES } from '@/constants/classes'; -import { Money } from '@/components'; import '@/style/components/BigAmount.scss'; -export function PageFormBigNumber({ label, amount, currencyCode }) { +interface PageFormBigNumberProps { + label: string; + amount: string | number; +} +export function PageFormBigNumber({ + label, + amount, +}: PageFormBigNumberProps) { return (
{label} -

- -

+

{amount}

); diff --git a/packages/webapp/src/components/TotalLines/index.tsx b/packages/webapp/src/components/TotalLines/index.tsx index ae97abd45..cc9379fe1 100644 --- a/packages/webapp/src/components/TotalLines/index.tsx +++ b/packages/webapp/src/components/TotalLines/index.tsx @@ -1,7 +1,7 @@ // @ts-nocheck import React from 'react'; import styled from 'styled-components'; - +import { x } from '@xstyled/emotion'; export const TotalLineBorderStyle = { None: 'None', SingleDark: 'SingleDark', @@ -32,14 +32,14 @@ export function TotalLines({ export function TotalLine({ title, value, borderStyle, textStyle, className }) { return ( -
{title}
{value}
-
+ ); } @@ -63,7 +63,7 @@ export const TotalLinesRoot = styled.div` `} `; -export const TotalLineRoot = styled.div` +export const TotalLinePrimitive = styled.div` display: table-row; .amount, @@ -96,5 +96,17 @@ export const TotalLineRoot = styled.div` .amount { text-align: right; + width: 25%; } `; + +const TotalLineAmount = (props) => { + return ; +}; + +export const TotalLineTitle = (props) => { + return ; +}; + +TotalLinePrimitive.Amount = TotalLineAmount; +TotalLinePrimitive.Title = TotalLineTitle; diff --git a/packages/webapp/src/constants/drawers.ts b/packages/webapp/src/constants/drawers.ts index cd1d064d4..6c75d8b2b 100644 --- a/packages/webapp/src/constants/drawers.ts +++ b/packages/webapp/src/constants/drawers.ts @@ -34,5 +34,8 @@ export enum DRAWERS { BRANDING_TEMPLATES = 'BRANDING_TEMPLATES', PAYMENT_INVOICE_PREVIEW = 'PAYMENT_INVOICE_PREVIEW', STRIPE_PAYMENT_INTEGRATION_EDIT = 'STRIPE_PAYMENT_INTEGRATION_EDIT', - INVOICE_SEND_MAIL = 'INVOICE_SEND_MAIL' + INVOICE_SEND_MAIL = 'INVOICE_SEND_MAIL', + ESTIMATE_SEND_MAIL = 'ESTIMATE_SEND_MAIL', + RECEIPT_SEND_MAIL = 'RECEIPT_SEND_MAIL', + PAYMENT_RECEIVED_SEND_MAIL = 'PAYMENT_RECEIVED_SEND_MAIL', } diff --git a/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalEntriesHeader.tsx b/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalEntriesHeader.tsx index fe3cf3cd3..174c66854 100644 --- a/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalEntriesHeader.tsx +++ b/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalEntriesHeader.tsx @@ -1,11 +1,10 @@ // @ts-nocheck import React from 'react'; import classNames from 'classnames'; -import { useFormikContext } from 'formik'; import { CLASSES } from '@/constants/classes'; -import { safeSumBy } from '@/utils'; import { PageFormBigNumber, FormattedMessage as T } from '@/components'; import MakeJournalEntriesHeaderFields from './MakeJournalEntriesHeaderFields'; +import { useManualJournalTotalFormatted } from './utils'; export default function MakeJournalEntriesHeader() { return ( @@ -21,19 +20,9 @@ export default function MakeJournalEntriesHeader() { * @returns {React.ReactNode} */ function MakeJournalHeaderBigNumber() { - const { - values: { entries, currency_code }, - } = useFormikContext(); - const totalCredit = safeSumBy(entries, 'credit'); - const totalDebit = safeSumBy(entries, 'debit'); - - const total = Math.max(totalCredit, totalDebit); + const totalFormatted = useManualJournalTotalFormatted(); return ( - } - amount={total} - currencyCode={currency_code} - /> + } amount={totalFormatted} /> ); } diff --git a/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalFormFooterRight.tsx b/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalFormFooterRight.tsx index 3aecd7e3d..59c8f6e0d 100644 --- a/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalFormFooterRight.tsx +++ b/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalFormFooterRight.tsx @@ -8,10 +8,14 @@ import { TotalLineBorderStyle, TotalLineTextStyle, } from '@/components'; -import { useJournalTotals } from './utils'; +import { + useManualJournalSubtotalFormatted, + useManualJournalTotalFormatted, +} from './utils'; export function MakeJournalFormFooterRight() { - const { formattedSubtotal, formattedTotal } = useJournalTotals(); + const formattedSubtotal = useManualJournalSubtotalFormatted(); + const formattedTotal = useManualJournalTotalFormatted(); return ( @@ -29,7 +33,7 @@ export function MakeJournalFormFooterRight() { ); } -const MakeJouranlTotalLines =styled(TotalLines)` +const MakeJouranlTotalLines = styled(TotalLines)` width: 100%; color: #555555; `; diff --git a/packages/webapp/src/containers/Accounting/MakeJournal/utils.tsx b/packages/webapp/src/containers/Accounting/MakeJournal/utils.tsx index ab7ff4c49..bd17c58af 100644 --- a/packages/webapp/src/containers/Accounting/MakeJournal/utils.tsx +++ b/packages/webapp/src/containers/Accounting/MakeJournal/utils.tsx @@ -4,7 +4,7 @@ import * as R from 'ramda'; import moment from 'moment'; import intl from 'react-intl-universal'; import { Intent } from '@blueprintjs/core'; -import { sumBy, setWith, toSafeInteger, get, first } from 'lodash'; +import { sumBy, setWith, get, first, toNumber } from 'lodash'; import { updateTableCell, repeatValue, @@ -91,8 +91,8 @@ export function transformToEditForm(manualJournal) { * Entries adjustment. */ function adjustmentEntries(entries) { - const credit = sumBy(entries, (e) => toSafeInteger(e.credit)); - const debit = sumBy(entries, (e) => toSafeInteger(e.debit)); + const credit = sumBy(entries, (e) => toNumber(e.credit)); + const debit = sumBy(entries, (e) => toNumber(e.debit)); return { debit: Math.max(credit - debit, 0), @@ -226,34 +226,73 @@ export const useSetPrimaryBranchToForm = () => { }, [isBranchesSuccess, setFieldValue, branches]); }; -/** - * Retreives the Journal totals. - */ -export const useJournalTotals = () => { - const { - values: { entries, currency_code: currencyCode }, - } = useFormikContext(); +export const useManualJournalCreditTotal = () => { + const { values } = useFormikContext(); + const totalCredit = safeSumBy(values.entries, 'credit'); - // Retrieves the invoice entries total. - const totalCredit = safeSumBy(entries, 'credit'); - const totalDebit = safeSumBy(entries, 'debit'); + return totalCredit; +}; - const total = Math.max(totalCredit, totalDebit); - // Retrieves the formatted total money. - const formattedTotal = React.useMemo( - () => formattedAmount(total, currencyCode), - [total, currencyCode], - ); - // Retrieves the formatted subtotal. - const formattedSubtotal = React.useMemo( - () => formattedAmount(total, currencyCode, { money: false }), - [total, currencyCode], - ); +export const useManualJournalCreditTotalFormatted = () => { + const totalCredit = useManualJournalCreditTotal(); + const { values } = useFormikContext(); - return { - formattedTotal, - formattedSubtotal, - }; + return formattedAmount(totalCredit, values.currency_code); +}; + +export const useManualJournalDebitTotal = () => { + const { values } = useFormikContext(); + const totalDebit = safeSumBy(values.entries, 'debit'); + + return totalDebit; +}; + +export const useManualJournalDebitTotalFormatted = () => { + const totalDebit = useManualJournalDebitTotal(); + const { values } = useFormikContext(); + + return formattedAmount(totalDebit, values.currency_code); +}; + +export const useManualJournalSubtotal = () => { + const totalCredit = useManualJournalCreditTotal(); + const totalDebit = useManualJournalDebitTotal(); + + return Math.max(totalCredit, totalDebit); +}; + +export const useManualJournalSubtotalFormatted = () => { + const subtotal = useManualJournalSubtotal(); + const { values } = useFormikContext(); + + return formattedAmount(subtotal, values.currency_code); +}; + +export const useManualJournalTotalDifference = () => { + const totalCredit = useManualJournalCreditTotal(); + const totalDebit = useManualJournalDebitTotal(); + + return Math.abs(totalCredit - totalDebit); +}; + +export const useManualJournalTotalDifferenceFormatted = () => { + const difference = useManualJournalTotalDifference(); + const { values } = useFormikContext(); + + return formattedAmount(difference, values.currency_code); +}; + +export const useManualJournalTotal = () => { + const total = useManualJournalSubtotal(); + + return total; +}; + +export const useManualJournalTotalFormatted = () => { + const total = useManualJournalTotal(); + const { values } = useFormikContext(); + + return formattedAmount(total, values.currency_code); }; /** diff --git a/packages/webapp/src/containers/Attachments/UploadAttachmentsPopoverContent.tsx b/packages/webapp/src/containers/Attachments/UploadAttachmentsPopoverContent.tsx index f755b42b4..ddcdc794c 100644 --- a/packages/webapp/src/containers/Attachments/UploadAttachmentsPopoverContent.tsx +++ b/packages/webapp/src/containers/Attachments/UploadAttachmentsPopoverContent.tsx @@ -22,7 +22,7 @@ interface AttachmentFileCommon { size: number; mimeType: string; } -interface AttachmentFileLoaded extends AttachmentFileCommon {} +interface AttachmentFileLoaded extends AttachmentFileCommon { } interface AttachmentFileLoading extends AttachmentFileCommon { loading: boolean; } @@ -74,11 +74,11 @@ export function UploadAttachmentsPopoverContent({ }; // Uploads the attachments. const { mutateAsync: uploadAttachments } = useUploadAttachments({ - onSuccess: (data) => { + onSuccess: (data, formData) => { const newLocalFiles = stopLoadingAttachment( localFiles, - data.config.data.get('internalKey'), - data.data.data.key, + formData.get('internalKey'), + data.key, ); handleFilesChange(newLocalFiles); onUploadedChange && onUploadedChange(newLocalFiles); diff --git a/packages/webapp/src/containers/Drawers/BillDrawer/BillDetailTable.tsx b/packages/webapp/src/containers/Drawers/BillDrawer/BillDetailTable.tsx index 3f1e73023..ea5ba5481 100644 --- a/packages/webapp/src/containers/Drawers/BillDrawer/BillDetailTable.tsx +++ b/packages/webapp/src/containers/Drawers/BillDrawer/BillDetailTable.tsx @@ -20,6 +20,10 @@ export default function BillDetailTable() { e.discount_formatted) ? [] : ['discount'] + } styleName={TableStyle.Constrant} /> ); diff --git a/packages/webapp/src/containers/Drawers/BillDrawer/BillDetailTableFooter.tsx b/packages/webapp/src/containers/Drawers/BillDrawer/BillDetailTableFooter.tsx index 17c29c1a4..eb6846afb 100644 --- a/packages/webapp/src/containers/Drawers/BillDrawer/BillDetailTableFooter.tsx +++ b/packages/webapp/src/containers/Drawers/BillDrawer/BillDetailTableFooter.tsx @@ -31,6 +31,23 @@ export function BillDetailTableFooter() { textStyle={TotalLineTextStyle.Regular} /> ))} + {bill.discount_amount > 0 && ( + + )} + {bill.adjustment_formatted && ( + + )} } value={bill.total_formatted} diff --git a/packages/webapp/src/containers/Drawers/BillDrawer/utils.tsx b/packages/webapp/src/containers/Drawers/BillDrawer/utils.tsx index 9b9691ddf..a25924eea 100644 --- a/packages/webapp/src/containers/Drawers/BillDrawer/utils.tsx +++ b/packages/webapp/src/containers/Drawers/BillDrawer/utils.tsx @@ -13,7 +13,6 @@ import { Tag, } from '@blueprintjs/core'; import { - FormatNumberCell, TextOverviewTooltipCell, FormattedMessage as T, Choose, @@ -51,9 +50,8 @@ export const useBillReadonlyEntriesTableColumns = () => { }, { Header: intl.get('quantity'), - accessor: 'quantity', - Cell: FormatNumberCell, - width: getColumnWidth(entries, 'quantity', { + accessor: 'quantity_formatted', + width: getColumnWidth(entries, 'quantity_formatted', { minWidth: 60, magicSpacing: 5, }), @@ -72,6 +70,18 @@ export const useBillReadonlyEntriesTableColumns = () => { disableSortBy: true, textOverview: true, }, + { + id: 'discount', + Header: 'Discount', + accessor: 'discount_formatted', + align: 'right', + disableSortBy: true, + textOverview: true, + width: getColumnWidth(entries, 'discount_formatted', { + minWidth: 60, + magicSpacing: 5, + }), + }, { Header: intl.get('amount'), accessor: 'total_formatted', diff --git a/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/CreditNoteDetailHeader.tsx b/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/CreditNoteDetailHeader.tsx index ffb1f7985..c5bec8978 100644 --- a/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/CreditNoteDetailHeader.tsx +++ b/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/CreditNoteDetailHeader.tsx @@ -30,7 +30,7 @@ export default function CreditNoteDetailHeader() { - {creditNote.formatted_amount} + {creditNote.total_formatted} diff --git a/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/CreditNoteDetailTable.tsx b/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/CreditNoteDetailTable.tsx index 08d200488..ae520cc27 100644 --- a/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/CreditNoteDetailTable.tsx +++ b/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/CreditNoteDetailTable.tsx @@ -22,6 +22,10 @@ export default function CreditNoteDetailTable() { e.discount_formatted) ? [] : ['discount'] + } className={'table-constrant'} /> ); diff --git a/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/CreditNoteDetailTableFooter.tsx b/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/CreditNoteDetailTableFooter.tsx index 6171686cf..f89fa389b 100644 --- a/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/CreditNoteDetailTableFooter.tsx +++ b/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/CreditNoteDetailTableFooter.tsx @@ -21,10 +21,27 @@ export default function CreditNoteDetailTableFooter() { } value={creditNote.formatted_subtotal} + borderStyle={TotalLineBorderStyle.SingleDark} /> + {creditNote.discount_amount > 0 && ( + + )} + {creditNote.adjustment_formatted && ( + + )} } - value={creditNote.formatted_amount} + value={creditNote.total_formatted} borderStyle={TotalLineBorderStyle.DoubleDark} textStyle={TotalLineTextStyle.Bold} /> diff --git a/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/utils.tsx b/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/utils.tsx index 85e57c5ee..ca8915d2f 100644 --- a/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/utils.tsx +++ b/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/utils.tsx @@ -16,7 +16,6 @@ import { Icon, FormattedMessage as T, TextOverviewTooltipCell, - FormatNumberCell, Choose, } from '@/components'; import { useCreditNoteDetailDrawerContext } from './CreditNoteDetailDrawerProvider'; @@ -48,9 +47,8 @@ export const useCreditNoteReadOnlyEntriesColumns = () => { }, { Header: intl.get('quantity'), - accessor: 'quantity', - Cell: FormatNumberCell, - width: getColumnWidth(entries, 'quantity', { + accessor: 'quantity_formatted', + width: getColumnWidth(entries, 'quantity_formatted', { minWidth: 60, magicSpacing: 5, }), @@ -69,6 +67,18 @@ export const useCreditNoteReadOnlyEntriesColumns = () => { disableSortBy: true, textOverview: true, }, + { + id: 'discount', + Header: 'Discount', + accessor: 'discount_formatted', + align: 'right', + disableSortBy: true, + textOverview: true, + width: getColumnWidth(entries, 'discount_formatted', { + minWidth: 60, + magicSpacing: 5, + }), + }, { Header: intl.get('amount'), accessor: 'total_formatted', diff --git a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailActionsBar.tsx b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailActionsBar.tsx index a01be889d..2d156dffe 100644 --- a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailActionsBar.tsx +++ b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailActionsBar.tsx @@ -30,7 +30,6 @@ import { import { compose } from '@/utils'; import { DRAWERS } from '@/constants/drawers'; -import { DialogsName } from '@/constants/dialogs'; /** * Estimate read-only details actions bar of the drawer. @@ -44,6 +43,7 @@ function EstimateDetailActionsBar({ // #withDrawerActions closeDrawer, + openDrawer }) { // Estimate details drawer context. const { estimateId, estimate } = useEstimateDetailDrawerContext(); @@ -80,7 +80,7 @@ function EstimateDetailActionsBar({ }; // Handles the estimate mail dialog. const handleMailEstimate = () => { - openDialog(DialogsName.EstimateMail, { estimateId }); + openDrawer(DRAWERS.ESTIMATE_SEND_MAIL, { estimateId }); }; return ( diff --git a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailHeader.tsx b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailHeader.tsx index a6ae51468..a448b8d96 100644 --- a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailHeader.tsx +++ b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailHeader.tsx @@ -30,7 +30,7 @@ export default function EstimateDetailHeader() { - {estimate.formatted_amount} + {estimate.total_formatted} diff --git a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailTable.tsx b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailTable.tsx index 26df57b6d..ee9ecada8 100644 --- a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailTable.tsx +++ b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailTable.tsx @@ -23,6 +23,10 @@ export default function EstimateDetailTable() { e.discount_formatted) ? [] : ['discount'] + } styleName={TableStyle.Constrant} /> ); diff --git a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailTableFooter.tsx b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailTableFooter.tsx index 8bc3ee96a..f2f02f8aa 100644 --- a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailTableFooter.tsx +++ b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailTableFooter.tsx @@ -25,9 +25,27 @@ export default function EstimateDetailTableFooter() { value={estimate.formatted_subtotal} borderStyle={TotalLineBorderStyle.SingleDark} /> + {estimate?.discount_amount_formatted && ( + + )} + {estimate?.adjustment_formatted && ( + + )} } - value={estimate.formatted_amount} + value={estimate.total_formatted} borderStyle={TotalLineBorderStyle.DoubleDark} textStyle={TotalLineTextStyle.Bold} /> diff --git a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/utils.tsx b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/utils.tsx index eb9def4e6..fbf43d1f6 100644 --- a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/utils.tsx +++ b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/utils.tsx @@ -55,6 +55,18 @@ export const useEstimateReadonlyEntriesColumns = () => { disableSortBy: true, textOverview: true, }, + { + id: 'discount', + Header: 'Discount', + accessor: 'discount_formatted', + align: 'right', + disableSortBy: true, + textOverview: true, + width: getColumnWidth(entries, 'discount_formatted', { + minWidth: 60, + magicSpacing: 5, + }), + }, { Header: intl.get('amount'), accessor: 'total_formatted', diff --git a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailHeader.tsx b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailHeader.tsx index 9231132a8..ea27e90c1 100644 --- a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailHeader.tsx +++ b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailHeader.tsx @@ -5,12 +5,10 @@ import styled from 'styled-components'; import { defaultTo } from 'lodash'; import { - ButtonLink, Row, Col, DetailsMenu, DetailItem, - FormatDate, CommercialDocHeader, CommercialDocTopHeader, CustomerDrawerLink, diff --git a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailTable.tsx b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailTable.tsx index fc9605148..da566b1e5 100644 --- a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailTable.tsx +++ b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailTable.tsx @@ -1,5 +1,6 @@ // @ts-nocheck import React from 'react'; +import * as R from 'ramda'; import { CommercialDocEntriesTable } from '@/components'; @@ -25,6 +26,10 @@ export default function InvoiceDetailTable() { columns={columns} data={entries} styleName={TableStyle.Constrant} + initialHiddenColumns={ + // If any entry has no discount, hide the discount column. + entries?.some((e) => e.discount_formatted) ? [] : ['discount'] + } /> ); } diff --git a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailTableFooter.tsx b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailTableFooter.tsx index dd225739c..1b3c26e3f 100644 --- a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailTableFooter.tsx +++ b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailTableFooter.tsx @@ -26,7 +26,25 @@ export function InvoiceDetailTableFooter() { value={invoice.subtotal_formatted} borderStyle={TotalLineBorderStyle.SingleDark} /> - {invoice.taxes.map((taxRate) => ( + {invoice?.discount_amount > 0 && ( + + )} + {invoice?.adjustment_formatted && ( + + )} + {invoice?.taxes?.map((taxRate) => ( { { Header: intl.get('quantity'), accessor: 'quantity', - Cell: FormatNumberCell, align: 'right', disableSortBy: true, textOverview: true, - width: getColumnWidth(entries, 'quantity', { + width: getColumnWidth(entries, 'quantity_formatted', { minWidth: 60, magicSpacing: 5, }), @@ -74,6 +73,18 @@ export const useInvoiceReadonlyEntriesColumns = () => { magicSpacing: 5, }), }, + { + id: 'discount', + Header: 'Discount', + accessor: 'discount_formatted', + align: 'right', + disableSortBy: true, + textOverview: true, + width: getColumnWidth(entries, 'discount_formatted', { + minWidth: 60, + magicSpacing: 5, + }), + }, { Header: intl.get('amount'), accessor: 'total_formatted', diff --git a/packages/webapp/src/containers/Drawers/ItemDetailDrawer/ItemPaymentTransactions/EstimatePaymentTransactions/components.tsx b/packages/webapp/src/containers/Drawers/ItemDetailDrawer/ItemPaymentTransactions/EstimatePaymentTransactions/components.tsx index 08ddf152f..8055c4137 100644 --- a/packages/webapp/src/containers/Drawers/ItemDetailDrawer/ItemPaymentTransactions/EstimatePaymentTransactions/components.tsx +++ b/packages/webapp/src/containers/Drawers/ItemDetailDrawer/ItemPaymentTransactions/EstimatePaymentTransactions/components.tsx @@ -72,7 +72,7 @@ export const useEstimateTransactionsColumns = () => { { id: 'qunatity', Header: intl.get('item.drawer_quantity_sold'), - accessor: 'quantity', + accessor: 'quantity_formatted', align: 'right', width: 100, }, diff --git a/packages/webapp/src/containers/Drawers/PaymentReceiveDetailDrawer/PaymentReceiveActionsBar.tsx b/packages/webapp/src/containers/Drawers/PaymentReceiveDetailDrawer/PaymentReceiveActionsBar.tsx index cad001a72..8f54f6098 100644 --- a/packages/webapp/src/containers/Drawers/PaymentReceiveDetailDrawer/PaymentReceiveActionsBar.tsx +++ b/packages/webapp/src/containers/Drawers/PaymentReceiveDetailDrawer/PaymentReceiveActionsBar.tsx @@ -28,7 +28,6 @@ import { import { compose } from '@/utils'; import { DRAWERS } from '@/constants/drawers'; -import { DialogsName } from '@/constants/dialogs'; /** * Payment receive actions bar. @@ -39,6 +38,7 @@ function PaymentsReceivedActionsBar({ // #withDrawerActions closeDrawer, + openDrawer, // #withDialogActions openDialog, @@ -69,8 +69,11 @@ function PaymentsReceivedActionsBar({ openDialog('payment-pdf-preview', { paymentReceiveId }); }; + // Handle mail action. const handleMailPaymentReceive = () => { - openDialog(DialogsName.PaymentMail, { paymentReceiveId }); + openDrawer(DRAWERS.PAYMENT_RECEIVED_SEND_MAIL, { + paymentReceivedId: paymentReceiveId, + }); }; return ( diff --git a/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/ReceiptDetailActionBar.tsx b/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/ReceiptDetailActionBar.tsx index 9ca00216a..85336c469 100644 --- a/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/ReceiptDetailActionBar.tsx +++ b/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/ReceiptDetailActionBar.tsx @@ -24,7 +24,6 @@ import { useReceiptDetailDrawerContext } from './ReceiptDetailDrawerProvider'; import { SaleReceiptAction, AbilitySubject } from '@/constants/abilityOption'; import { safeCallback, compose } from '@/utils'; import { DRAWERS } from '@/constants/drawers'; -import { DialogsName } from '@/constants/dialogs'; /** * Receipt details actions bar. @@ -39,6 +38,7 @@ function ReceiptDetailActionBar({ // #withDrawerActions closeDrawer, + openDrawer }) { const history = useHistory(); const { receiptId } = useReceiptDetailDrawerContext(); @@ -61,8 +61,9 @@ function ReceiptDetailActionBar({ const handleNotifyViaSMS = () => { openDialog('notify-receipt-via-sms', { receiptId }); }; + // Handle receipt mail action. const handleReceiptMail = () => { - openDialog(DialogsName.ReceiptMail, { receiptId }); + openDrawer(DRAWERS.RECEIPT_SEND_MAIL, { receiptId }); }; return ( diff --git a/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/ReceiptDetailHeader.tsx b/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/ReceiptDetailHeader.tsx index a55d47ee6..78f969031 100644 --- a/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/ReceiptDetailHeader.tsx +++ b/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/ReceiptDetailHeader.tsx @@ -5,14 +5,12 @@ import styled from 'styled-components'; import { defaultTo } from 'lodash'; import { - ButtonLink, CustomerDrawerLink, CommercialDocHeader, CommercialDocTopHeader, ExchangeRateDetailItem, Row, Col, - FormatDate, DetailsMenu, DetailItem, } from '@/components'; @@ -31,7 +29,7 @@ export default function ReceiptDetailHeader() { -

{receipt.formatted_amount}

+

{receipt.total_formatted}

@@ -66,6 +64,7 @@ export default function ReceiptDetailHeader() { />
+ e.discount_formatted) ? [] : ['discount'] + } styleName={TableStyle.Constrant} /> ); diff --git a/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/ReceiptDetailTableFooter.tsx b/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/ReceiptDetailTableFooter.tsx index b5687a091..d2b841598 100644 --- a/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/ReceiptDetailTableFooter.tsx +++ b/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/ReceiptDetailTableFooter.tsx @@ -8,7 +8,6 @@ import { TotalLine, TotalLineBorderStyle, TotalLineTextStyle, - FormatNumber, } from '@/components'; import { useReceiptDetailDrawerContext } from './ReceiptDetailDrawerProvider'; @@ -23,18 +22,36 @@ export default function ReceiptDetailTableFooter() { } - value={receipt.formatted_subtotal} + value={receipt.subtotal_formatted} /> + {receipt.discount_amount > 0 && ( + + )} + {receipt.adjustment_formatted && ( + + )} } - value={receipt.formatted_amount} + value={receipt.total_formatted} borderStyle={TotalLineBorderStyle.DoubleDark} textStyle={TotalLineTextStyle.Bold} /> } - value={receipt.formatted_amount} - borderStyle={TotalLineBorderStyle.DoubleDark} + value={receipt.paid_formatted} + borderStyle={TotalLineBorderStyle.SingleDark} /> } diff --git a/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/utils.tsx b/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/utils.tsx index 0241d3e76..693655f76 100644 --- a/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/utils.tsx +++ b/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/utils.tsx @@ -31,9 +31,8 @@ export const useReceiptReadonlyEntriesTableColumns = () => { }, { Header: intl.get('quantity'), - accessor: 'quantity', - Cell: FormatNumberCell, - width: getColumnWidth(entries, 'quantity', { + accessor: 'quantity_formatted', + width: getColumnWidth(entries, 'quantity_formatted', { minWidth: 60, magicSpacing: 5, }), @@ -51,6 +50,18 @@ export const useReceiptReadonlyEntriesTableColumns = () => { disableSortBy: true, textOverview: true, }, + { + id: 'discount', + Header: 'Discount', + accessor: 'discount_formatted', + align: 'right', + disableSortBy: true, + textOverview: true, + width: getColumnWidth(entries, 'discount_formatted', { + minWidth: 60, + magicSpacing: 5, + }), + }, { Header: intl.get('amount'), accessor: 'amount', diff --git a/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/VendorCreditDetailDrawerFooter.tsx b/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/VendorCreditDetailDrawerFooter.tsx index 0bfd435e9..17c9a4192 100644 --- a/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/VendorCreditDetailDrawerFooter.tsx +++ b/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/VendorCreditDetailDrawerFooter.tsx @@ -25,9 +25,27 @@ export default function VendorCreditDetailDrawerFooter() { value={vendorCredit.formatted_subtotal} borderStyle={TotalLineBorderStyle.SingleDark} /> + {vendorCredit?.discount_amount_formatted && ( + + )} + {vendorCredit?.adjustment_formatted && ( + + )} } - value={vendorCredit.formatted_amount} + value={vendorCredit.total_formatted} borderStyle={TotalLineBorderStyle.DoubleDark} textStyle={TotalLineTextStyle.Bold} /> diff --git a/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/VendorCreditDetailHeader.tsx b/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/VendorCreditDetailHeader.tsx index 989b1b245..fac1f8494 100644 --- a/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/VendorCreditDetailHeader.tsx +++ b/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/VendorCreditDetailHeader.tsx @@ -5,7 +5,6 @@ import styled from 'styled-components'; import { defaultTo } from 'lodash'; import { - FormatDate, T, Row, Col, @@ -29,13 +28,14 @@ export default function VendorCreditDetailHeader() { - {vendorCredit.formatted_amount} + {vendorCredit.total_formatted} + diff --git a/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/VendorCreditDetailTable.tsx b/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/VendorCreditDetailTable.tsx index 74823af9c..58bdcaefa 100644 --- a/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/VendorCreditDetailTable.tsx +++ b/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/VendorCreditDetailTable.tsx @@ -23,6 +23,10 @@ export default function VendorCreditDetailTable() { e.discount_formatted) ? [] : ['discount'] + } styleName={TableStyle.Constrant} /> ); diff --git a/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/utils.tsx b/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/utils.tsx index 63007d86a..9bcbac3e9 100644 --- a/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/utils.tsx +++ b/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/utils.tsx @@ -16,7 +16,6 @@ import { Icon, FormattedMessage as T, TextOverviewTooltipCell, - FormatNumberCell, Choose, } from '@/components'; import { useVendorCreditDetailDrawerContext } from './VendorCreditDetailDrawerProvider'; @@ -69,6 +68,18 @@ export const useVendorCreditReadonlyEntriesTableColumns = () => { disableSortBy: true, textOverview: true, }, + { + id: 'discount', + Header: 'Discount', + accessor: 'discount_formatted', + width: getColumnWidth(entries, 'discount_formatted', { + minWidth: 60, + magicSpacing: 5, + }), + align: 'right', + disableSortBy: true, + textOverview: true, + }, { Header: intl.get('amount'), accessor: 'total_formatted', diff --git a/packages/webapp/src/containers/Expenses/ExpenseForm/ExpenseFormFooterRight.tsx b/packages/webapp/src/containers/Expenses/ExpenseForm/ExpenseFormFooterRight.tsx index 31b1172d5..0eb41999e 100644 --- a/packages/webapp/src/containers/Expenses/ExpenseForm/ExpenseFormFooterRight.tsx +++ b/packages/webapp/src/containers/Expenses/ExpenseForm/ExpenseFormFooterRight.tsx @@ -8,21 +8,25 @@ import { TotalLineBorderStyle, TotalLineTextStyle, } from '@/components'; -import { useExpensesTotals } from './utils'; +import { + useExpenseSubtotalFormatted, + useExpenseTotalFormatted, +} from './utils'; export function ExpenseFormFooterRight() { - const { formattedSubtotal, formattedTotal } = useExpensesTotals(); + const totalFormatted = useExpenseTotalFormatted(); + const subtotalFormatted = useExpenseSubtotalFormatted(); return ( } - value={formattedSubtotal} + value={subtotalFormatted} borderStyle={TotalLineBorderStyle.None} /> } - value={formattedTotal} + value={totalFormatted} textStyle={TotalLineTextStyle.Bold} /> diff --git a/packages/webapp/src/containers/Expenses/ExpenseForm/ExpenseFormHeader.tsx b/packages/webapp/src/containers/Expenses/ExpenseForm/ExpenseFormHeader.tsx index 97dbd2399..858b0bc35 100644 --- a/packages/webapp/src/containers/Expenses/ExpenseForm/ExpenseFormHeader.tsx +++ b/packages/webapp/src/containers/Expenses/ExpenseForm/ExpenseFormHeader.tsx @@ -1,34 +1,23 @@ // @ts-nocheck -import React, { useMemo } from 'react'; +import React from 'react'; import classNames from 'classnames'; -import { sumBy } from 'lodash'; -import { useFormikContext } from 'formik'; import { FormattedMessage as T } from '@/components'; import { CLASSES } from '@/constants/classes'; - import ExpenseFormHeaderFields from './ExpenseFormHeaderFields'; import { PageFormBigNumber } from '@/components'; +import { useExpenseTotalFormatted } from './utils'; // Expense form header. export default function ExpenseFormHeader() { - const { - values: { currency_code, categories }, - } = useFormikContext(); - - // Calculates the expense entries amount. - const totalExpenseAmount = useMemo( - () => sumBy(categories, 'amount'), - [categories], - ); + const totalFormatted = useExpenseTotalFormatted(); return (
} - amount={totalExpenseAmount} - currencyCode={currency_code} + amount={totalFormatted} />
); diff --git a/packages/webapp/src/containers/Expenses/ExpenseForm/utils.tsx b/packages/webapp/src/containers/Expenses/ExpenseForm/utils.tsx index e411d810a..19cb5ab0a 100644 --- a/packages/webapp/src/containers/Expenses/ExpenseForm/utils.tsx +++ b/packages/webapp/src/containers/Expenses/ExpenseForm/utils.tsx @@ -166,30 +166,52 @@ export const useSetPrimaryBranchToForm = () => { }; /** - * Retreives the Journal totals. + * Retrieves the expense subtotal. + * @returns {number} */ -export const useExpensesTotals = () => { +export const useExpenseSubtotal = () => { const { - values: { categories, currency_code: currencyCode }, + values: { categories }, } = useFormikContext(); - const total = sumBy(categories, 'amount'); + // Calculates the expense entries amount. + return React.useMemo(() => sumBy(categories, 'amount'), [categories]); +}; - // Retrieves the formatted total money. - const formattedTotal = React.useMemo( - () => formattedAmount(total, currencyCode), - [total, currencyCode], - ); - // Retrieves the formatted subtotal. - const formattedSubtotal = React.useMemo( - () => formattedAmount(total, currencyCode, { money: false }), - [total, currencyCode], - ); +/** + * Retrieves the expense subtotal formatted. + * @returns {string} + */ +export const useExpenseSubtotalFormatted = () => { + const subtotal = useExpenseSubtotal(); + const { + values: { currency_code }, + } = useFormikContext(); - return { - formattedTotal, - formattedSubtotal, - }; + return formattedAmount(subtotal, currency_code); +}; + +/** + * Retrieves the expense total. + * @returns {number} + */ +export const useExpenseTotal = () => { + const subtotal = useExpenseSubtotal(); + + return subtotal; +}; + +/** + * Retrieves the expense total formatted. + * @returns {string} + */ +export const useExpenseTotalFormatted = () => { + const total = useExpenseTotal(); + const { + values: { currency_code }, + } = useFormikContext(); + + return formattedAmount(total, currency_code); }; /** diff --git a/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormFooterRight.tsx b/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormFooterRight.tsx index 0657299d6..293e63818 100644 --- a/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormFooterRight.tsx +++ b/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormFooterRight.tsx @@ -1,28 +1,37 @@ // @ts-nocheck import styled from 'styled-components'; +import { useFormikContext } from 'formik'; import { TotalLines, TotalLine, TotalLineBorderStyle, TotalLineTextStyle, } from '@/components'; -import { useBillAggregatedTaxRates, useBillTotals } from './utils'; -import { useFormikContext } from 'formik'; +import { + useBillAdjustmentAmountFormatted, + useBillAggregatedTaxRates, + useBillDiscountAmountFormatted, + useBillDueAmountFormatted, + useBillPaidAmountFormatted, + useBillSubtotalFormatted, + useBillTotalFormatted, +} from './utils'; import { TaxType } from '@/interfaces/TaxRates'; +import { AdjustmentTotalLine } from '@/containers/Sales/Invoices/InvoiceForm/AdjustmentTotalLine'; +import { DiscountTotalLine } from '@/containers/Sales/Invoices/InvoiceForm/DiscountTotalLine'; export function BillFormFooterRight() { - const { - formattedSubtotal, - formattedTotal, - formattedDueTotal, - formattedPaymentTotal, - } = useBillTotals(); - const { values: { inclusive_exclusive_tax, currency_code }, } = useFormikContext(); + const dueAmountFormatted = useBillDueAmountFormatted(); + const paidAmountFormatted = useBillPaidAmountFormatted(); + const subtotalFormatted = useBillSubtotalFormatted(); + const totalFormatted = useBillTotalFormatted(); const taxEntries = useBillAggregatedTaxRates(); + const discountAmount = useBillDiscountAmountFormatted(); + const adjustmentAmount = useBillAdjustmentAmountFormatted(); return ( @@ -34,9 +43,13 @@ export function BillFormFooterRight() { : 'Subtotal'} } - value={formattedSubtotal} - borderStyle={TotalLineBorderStyle.None} + value={subtotalFormatted} /> + + {taxEntries.map((tax, index) => ( ))} diff --git a/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormHeader.tsx b/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormHeader.tsx index b3439fa4c..d4e5149d4 100644 --- a/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormHeader.tsx +++ b/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormHeader.tsx @@ -1,13 +1,12 @@ // @ts-nocheck -import React, { useMemo } from 'react'; +import React from 'react'; import intl from 'react-intl-universal'; import classNames from 'classnames'; -import { sumBy } from 'lodash'; -import { useFormikContext } from 'formik'; import { CLASSES } from '@/constants/classes'; import { PageFormBigNumber } from '@/components'; import BillFormHeaderFields from './BillFormHeaderFields'; +import { useBillTotalFormatted } from './utils'; /** * Fill form header. @@ -22,19 +21,10 @@ function BillFormHeader() { } function BillFormBigTotal() { - const { - values: { currency_code, entries }, - } = useFormikContext(); - - // Calculate the total due amount of bill entries. - const totalDueAmount = useMemo(() => sumBy(entries, 'amount'), [entries]); + const totalFormatted = useBillTotalFormatted(); return ( - + ); } diff --git a/packages/webapp/src/containers/Purchases/Bills/BillForm/utils.tsx b/packages/webapp/src/containers/Purchases/Bills/BillForm/utils.tsx index e4f3e41e4..b570b132c 100644 --- a/packages/webapp/src/containers/Purchases/Bills/BillForm/utils.tsx +++ b/packages/webapp/src/containers/Purchases/Bills/BillForm/utils.tsx @@ -13,6 +13,7 @@ import { repeatValue, orderingLinesIndexes, formattedAmount, + toSafeNumber, } from '@/utils'; import { updateItemsEntriesTotal, @@ -65,6 +66,13 @@ export const defaultBill = { currency_code: '', entries: [...repeatValue(defaultBillEntry, MIN_LINES_NUMBER)], attachments: [], + + // Adjustment + adjustment: '', + + // Discount + discount: '', + discount_type: 'amount', }; export const ERRORS = { @@ -252,58 +260,6 @@ export const useSetPrimaryWarehouseToForm = () => { }, [isWarehousesSuccess, setFieldValue, warehouses]); }; -/** - * Retreives the bill totals. - */ -export const useBillTotals = () => { - const { - values: { currency_code: currencyCode }, - } = useFormikContext(); - - // Retrieves the bill subtotal. - const subtotal = useBillSubtotal(); - const total = useBillTotal(); - - // Retrieves the formatted total money. - const formattedTotal = React.useMemo( - () => formattedAmount(total, currencyCode), - [total, currencyCode], - ); - // Retrieves the formatted subtotal. - const formattedSubtotal = React.useMemo( - () => formattedAmount(subtotal, currencyCode, { money: false }), - [subtotal, currencyCode], - ); - // Retrieves the payment total. - const paymentTotal = React.useMemo(() => 0, []); - - // Retireves the formatted payment total. - const formattedPaymentTotal = React.useMemo( - () => formattedAmount(paymentTotal, currencyCode), - [paymentTotal, currencyCode], - ); - // Retrieves the formatted due total. - const dueTotal = React.useMemo( - () => total - paymentTotal, - [total, paymentTotal], - ); - // Retrieves the formatted due total. - const formattedDueTotal = React.useMemo( - () => formattedAmount(dueTotal, currencyCode), - [dueTotal, currencyCode], - ); - - return { - total, - paymentTotal, - dueTotal, - formattedTotal, - formattedSubtotal, - formattedPaymentTotal, - formattedDueTotal, - }; -}; - /** * Detarmines whether the bill has foreign customer. * @returns {boolean} @@ -364,7 +320,64 @@ export const useBillSubtotal = () => { }; /** - * Retreives the bill total tax amount. + * Retrieves the bill subtotal formatted. + * @returns {string} + */ +export const useBillSubtotalFormatted = () => { + const subtotal = useBillSubtotal(); + const { values } = useFormikContext(); + + return formattedAmount(subtotal, values.currency_code); +}; + +/** + * Retrieves the bill discount amount. + * @returns {number} + */ +export const useBillDiscountAmount = () => { + const { values } = useFormikContext(); + const subtotal = useBillSubtotal(); + const discount = toSafeNumber(values.discount); + + return values?.discount_type === 'percentage' + ? (subtotal * discount) / 100 + : discount; +}; + +/** + * Retrieves the bill discount amount formatted. + * @returns {string} + */ +export const useBillDiscountAmountFormatted = () => { + const discountAmount = useBillDiscountAmount(); + const { values } = useFormikContext(); + + return formattedAmount(discountAmount, values.currency_code); +}; + +/** + * Retrieves the bill adjustment amount. + * @returns {number} + */ +export const useBillAdjustmentAmount = () => { + const { values } = useFormikContext(); + + return toSafeNumber(values.adjustment); +}; + +/** + * Retrieves the bill adjustment amount formatted. + * @returns {string} + */ +export const useBillAdjustmentAmountFormatted = () => { + const adjustmentAmount = useBillAdjustmentAmount(); + const { values } = useFormikContext(); + + return formattedAmount(adjustmentAmount, values.currency_code); +}; + +/** + * Retrieves the bill total tax amount. * @returns {number} */ export const useBillTotalTaxAmount = () => { @@ -389,15 +402,73 @@ export const useIsBillTaxExclusive = () => { }; /** - * Retreives the bill total. + * Retrieves the bill total. * @returns {number} */ export const useBillTotal = () => { const subtotal = useBillSubtotal(); const totalTaxAmount = useBillTotalTaxAmount(); const isExclusiveTax = useIsBillTaxExclusive(); + const discountAmount = useBillDiscountAmount(); + const adjustmentAmount = useBillAdjustmentAmount(); - return R.compose(R.when(R.always(isExclusiveTax), R.add(totalTaxAmount)))( - subtotal, - ); + return R.compose( + R.when(R.always(isExclusiveTax), R.add(totalTaxAmount)), + R.subtract(R.__, discountAmount), + R.add(R.__, adjustmentAmount), + )(subtotal); +}; + +/** + * Retrieves the bill total formatted. + * @returns {string} + */ +export const useBillTotalFormatted = () => { + const total = useBillTotal(); + const { values } = useFormikContext(); + + return formattedAmount(total, values.currency_code); +}; + +/** + * Retrieves the bill paid amount. + * @returns {number} + */ +export const useBillPaidAmount = () => { + const { values } = useFormikContext(); + + return toSafeNumber(0); +}; + +/** + * Retrieves the bill paid amount formatted. + * @returns {string} + */ +export const useBillPaidAmountFormatted = () => { + const paidAmount = useBillPaidAmount(); + const { values } = useFormikContext(); + + return formattedAmount(paidAmount, values.currency_code); +}; + +/** + * Retrieves the bill due amount. + * @returns {number} + */ +export const useBillDueAmount = () => { + const total = useBillTotal(); + const paidAmount = useBillPaidAmount(); + + return total - paidAmount; +}; + +/** + * Retrieves the bill due amount formatted. + * @returns {string} + */ +export const useBillDueAmountFormatted = () => { + const dueAmount = useBillDueAmount(); + const { values } = useFormikContext(); + + return formattedAmount(dueAmount, values.currency_code); }; diff --git a/packages/webapp/src/containers/Purchases/CreditNotes/CreditNoteForm/VendorCreditNoteFormFooterRight.tsx b/packages/webapp/src/containers/Purchases/CreditNotes/CreditNoteForm/VendorCreditNoteFormFooterRight.tsx index 7d6faba57..5448139b3 100644 --- a/packages/webapp/src/containers/Purchases/CreditNotes/CreditNoteForm/VendorCreditNoteFormFooterRight.tsx +++ b/packages/webapp/src/containers/Purchases/CreditNotes/CreditNoteForm/VendorCreditNoteFormFooterRight.tsx @@ -1,17 +1,26 @@ // @ts-nocheck import React from 'react'; import styled from 'styled-components'; +import { useFormikContext } from 'formik'; +import { T, TotalLines, TotalLine, TotalLineTextStyle } from '@/components'; import { - T, - TotalLines, - TotalLine, - TotalLineBorderStyle, - TotalLineTextStyle, -} from '@/components'; -import { useVendorCrditNoteTotals } from './utils'; + useVendorCreditAdjustmentAmountFormatted, + useVendorCreditDiscountAmountFormatted, + useVendorCreditSubtotalFormatted, + useVendorCreditTotalFormatted, +} from './utils'; +import { DiscountTotalLine } from '@/containers/Sales/Invoices/InvoiceForm/DiscountTotalLine'; +import { AdjustmentTotalLine } from '@/containers/Sales/Invoices/InvoiceForm/AdjustmentTotalLine'; export function VendorCreditNoteFormFooterRight() { - const { formattedSubtotal, formattedTotal } = useVendorCrditNoteTotals(); + const { + values: { currency_code }, + } = useFormikContext(); + const totalFormatted = useVendorCreditTotalFormatted(); + const subtotalFormatted = useVendorCreditSubtotalFormatted(); + + const discountAmountFormatted = useVendorCreditDiscountAmountFormatted(); + const adjustmentAmountFormatted = useVendorCreditAdjustmentAmountFormatted(); return ( } - value={formattedSubtotal} - borderStyle={TotalLineBorderStyle.None} + value={subtotalFormatted} /> + + } - value={formattedTotal} + value={totalFormatted} textStyle={TotalLineTextStyle.Bold} /> diff --git a/packages/webapp/src/containers/Purchases/CreditNotes/CreditNoteForm/VendorCreditNoteFormHeader.tsx b/packages/webapp/src/containers/Purchases/CreditNotes/CreditNoteForm/VendorCreditNoteFormHeader.tsx index 4ae780e45..9c1b66a5a 100644 --- a/packages/webapp/src/containers/Purchases/CreditNotes/CreditNoteForm/VendorCreditNoteFormHeader.tsx +++ b/packages/webapp/src/containers/Purchases/CreditNotes/CreditNoteForm/VendorCreditNoteFormHeader.tsx @@ -2,33 +2,23 @@ import React from 'react'; import intl from 'react-intl-universal'; import classNames from 'classnames'; -import { useFormikContext } from 'formik'; import { CLASSES } from '@/constants/classes'; import VendorCreditNoteFormHeaderFields from './VendorCreditNoteFormHeaderFields'; - -import { getEntriesTotal } from '@/containers/Entries/utils'; import { PageFormBigNumber } from '@/components'; - +import { useVendorCreditTotalFormatted } from './utils'; /** * Vendor Credit note header. */ function VendorCreditNoteFormHeader() { - const { values:{entries ,currency_code} } = useFormikContext(); - - // Calculate the total amount. - const totalAmount = React.useMemo( - () => getEntriesTotal(entries), - [entries], - ); + const totalFormatted = useVendorCreditTotalFormatted(); return (
); diff --git a/packages/webapp/src/containers/Purchases/CreditNotes/CreditNoteForm/utils.tsx b/packages/webapp/src/containers/Purchases/CreditNotes/CreditNoteForm/utils.tsx index a4ce4c66a..7724629be 100644 --- a/packages/webapp/src/containers/Purchases/CreditNotes/CreditNoteForm/utils.tsx +++ b/packages/webapp/src/containers/Purchases/CreditNotes/CreditNoteForm/utils.tsx @@ -11,6 +11,7 @@ import { transactionNumber, orderingLinesIndexes, formattedAmount, + toSafeNumber, } from '@/utils'; import { updateItemsEntriesTotal, @@ -53,6 +54,9 @@ export const defaultVendorsCreditNote = { currency_code: '', entries: [...repeatValue(defaultCreditNoteEntry, MIN_LINES_NUMBER)], attachments: [], + discount: '', + discount_type: 'amount', + adjustment: '', }; /** @@ -181,30 +185,121 @@ export const useSetPrimaryWarehouseToForm = () => { }, [isWarehousesSuccess, setFieldValue, warehouses]); }; -export const useVendorCrditNoteTotals = () => { +/** + * Retrieves the vendor credit subtotal. + * @returns {number} + */ +export const useVendorCreditSubtotal = () => { const { - values: { entries, currency_code: currencyCode }, + values: { entries }, } = useFormikContext(); // Retrieves the invoice entries total. const total = React.useMemo(() => getEntriesTotal(entries), [entries]); - // Retrieves the formatted total money. - const formattedTotal = React.useMemo( - () => formattedAmount(total, currencyCode), - [total, currencyCode], - ); - // Retrieves the formatted subtotal. - const formattedSubtotal = React.useMemo( - () => formattedAmount(total, currencyCode, { money: false }), - [total, currencyCode], - ); + return total; +}; - return { - total, - formattedTotal, - formattedSubtotal, - }; +/** + * Retrieves the vendor credit discount amount. + * @returns {number} + */ +export const useVendorCreditDiscountAmount = () => { + const { values } = useFormikContext(); + const subtotal = useVendorCreditSubtotal(); + const discount = toSafeNumber(values.discount); + + return values.discount_type === 'percentage' + ? (subtotal * discount) / 100 + : discount; +}; + +/** + * Retrieves the vendor credit discount amount formatted. + * @returns {string} + */ +export const useVendorCreditDiscountAmountFormatted = () => { + const discountAmount = useVendorCreditDiscountAmount(); + const { + values: { currency_code: currencyCode }, + } = useFormikContext(); + + return formattedAmount(discountAmount, currencyCode); +}; + +/** + * Retrieves the vendor credit adjustment amount. + * @returns {number} + */ +export const useVendorCreditAdjustment = () => { + const { values } = useFormikContext(); + + return toSafeNumber(values.adjustment); +}; + +/** + * Retrieves the vendor credit adjustment amount formatted. + * @returns {string} + */ +export const useVendorCreditAdjustmentAmountFormatted = () => { + const adjustmentAmount = useVendorCreditAdjustment(); + const { + values: { currency_code: currencyCode }, + } = useFormikContext(); + + return formattedAmount(adjustmentAmount, currencyCode); +}; + +/** + * Retrieves the vendor credit total. + * @returns {number} + */ +export const useVendorCreditTotal = () => { + const subtotal = useVendorCreditSubtotal(); + const discountAmount = useVendorCreditDiscountAmount(); + const adjustment = useVendorCreditAdjustment(); + + return R.compose( + R.subtract(R.__, discountAmount), + R.add(R.__, adjustment), + )(subtotal); +}; + +/** + * Retrieves the vendor credit total formatted. + * @returns {string} + */ +export const useVendorCreditTotalFormatted = () => { + const total = useVendorCreditTotal(); + const { + values: { currency_code: currencyCode }, + } = useFormikContext(); + + return formattedAmount(total, currencyCode); +}; + +/** + * Retrieves the vendor credit formatted subtotal. + * @returns {string} + */ +export const useVendorCreditFormattedSubtotal = () => { + const subtotal = useVendorCreditSubtotal(); + const currencyCode = useCurrentOrganizationCurrencyCode(); + + return formattedAmount(subtotal, currencyCode); +}; + +/** + * Retrieves the vendor credit formatted subtotal. + * @returns {string} + */ +export const useVendorCreditSubtotalFormatted = () => { + const subtotal = useVendorCreditSubtotal(); + const { + values: { currency_code: currencyCode }, + } = useFormikContext(); + + return formattedAmount(subtotal, currencyCode); }; /** diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormFooterRight.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormFooterRight.tsx index 7aa4a9fea..e6b381283 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormFooterRight.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormFooterRight.tsx @@ -1,6 +1,7 @@ // @ts-nocheck import React from 'react'; import styled from 'styled-components'; +import { useFormikContext } from 'formik'; import { T, TotalLines, @@ -8,21 +9,40 @@ import { TotalLineBorderStyle, TotalLineTextStyle, } from '@/components'; -import { useCreditNoteTotals } from './utils'; +import { + useCreditNoteAdjustmentFormatted, + useCreditNoteDiscountAmountFormatted, + useCreditNoteSubtotalFormatted, + useCreditNoteTotalFormatted, +} from './utils'; +import { DiscountTotalLine } from '../../Invoices/InvoiceForm/DiscountTotalLine'; +import { AdjustmentTotalLine } from '../../Invoices/InvoiceForm/AdjustmentTotalLine'; export function CreditNoteFormFooterRight() { - const { formattedSubtotal, formattedTotal } = useCreditNoteTotals(); + const { + values: { currency_code }, + } = useFormikContext(); + + const subtotalFormatted = useCreditNoteSubtotalFormatted(); + const totalFormatted = useCreditNoteTotalFormatted(); + const discountAmount = useCreditNoteDiscountAmountFormatted(); + const adjustmentAmount = useCreditNoteAdjustmentFormatted(); return ( } - value={formattedSubtotal} - borderStyle={TotalLineBorderStyle.None} + value={subtotalFormatted} + borderStyle={TotalLineBorderStyle.BorderBottom} /> + + } - value={formattedTotal} + value={totalFormatted} textStyle={TotalLineTextStyle.Bold} /> diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormHeader.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormHeader.tsx index 06ed4837d..9a3f450e1 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormHeader.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormHeader.tsx @@ -1,11 +1,9 @@ // @ts-nocheck import React from 'react'; import intl from 'react-intl-universal'; -import { useFormikContext } from 'formik'; import CreditNoteFormHeaderFields from './CreditNoteFormHeaderFields'; - -import { getEntriesTotal } from '@/containers/Entries/utils'; import { Group, PageFormBigNumber } from '@/components'; +import { useCreditNoteTotalFormatted } from './utils'; /** * Credit note header. @@ -31,18 +29,12 @@ function CreditNoteFormHeader() { * @returns {React.ReactNode} */ function CreditNoteFormBigNumber() { - const { - values: { entries, currency_code }, - } = useFormikContext(); - - // Calculate the total amount. - const totalAmount = React.useMemo(() => getEntriesTotal(entries), [entries]); + const totalFormatted = useCreditNoteTotalFormatted(); return ( ); } diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/components.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/components.tsx index afe139f48..ed1aafe3d 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/components.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/components.tsx @@ -4,7 +4,7 @@ import { useFormikContext } from 'formik'; import * as R from 'ramda'; import { ExchangeRateInputGroup } from '@/components'; import { useCurrentOrganization } from '@/hooks/state'; -import { useCreditNoteIsForeignCustomer, useCreditNoteTotals } from './utils'; +import { useCreditNoteIsForeignCustomer, useCreditNoteSubtotal } from './utils'; import withSettings from '@/containers/Settings/withSettings'; import { transactionNumber } from '@/utils'; import { @@ -78,13 +78,13 @@ export const CreditNoteSyncIncrementSettingsToForm = R.compose( */ export const CreditNoteExchangeRateSync = R.compose(withDialogActions)( ({ openDialog }) => { - const { total } = useCreditNoteTotals(); + const subtotal = useCreditNoteSubtotal(); const timeout = useRef(); useSyncExRateToForm({ onSynced: () => { // If the total bigger then zero show alert to the user after adjusting entries. - if (total > 0) { + if (subtotal > 0) { clearTimeout(timeout.current); timeout.current = setTimeout(() => { openDialog(DialogsName.InvoiceExchangeRateChangeNotice); diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/utils.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/utils.tsx index fad59288f..470cc3f5c 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/utils.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/utils.tsx @@ -10,6 +10,7 @@ import { repeatValue, formattedAmount, orderingLinesIndexes, + toSafeNumber, } from '@/utils'; import { useFormikContext } from 'formik'; import { useCreditNoteFormContext } from './CreditNoteFormProvider'; @@ -57,6 +58,9 @@ export const defaultCreditNote = { entries: [...repeatValue(defaultCreditNoteEntry, MIN_LINES_NUMBER)], attachments: [], pdf_template_id: '', + discount: '', + discount_type: 'amount', + adjustment: '', }; /** @@ -174,32 +178,108 @@ export const useSetPrimaryWarehouseToForm = () => { }; /** - * Retreives the credit note totals. + * Retrieves the credit note subtotal. + * @returns {number} */ -export const useCreditNoteTotals = () => { +export const useCreditNoteSubtotal = () => { const { - values: { entries, currency_code: currencyCode }, + values: { entries }, } = useFormikContext(); - // Retrieves the invoice entries total. const total = React.useMemo(() => getEntriesTotal(entries), [entries]); - // Retrieves the formatted total money. - const formattedTotal = React.useMemo( - () => formattedAmount(total, currencyCode), - [total, currencyCode], - ); - // Retrieves the formatted subtotal. - const formattedSubtotal = React.useMemo( - () => formattedAmount(total, currencyCode, { money: false }), - [total, currencyCode], - ); + return total; +}; - return { - total, - formattedTotal, - formattedSubtotal, - }; +/** + * Retrieves the credit note subtotal formatted. + * @returns {string} + */ +export const useCreditNoteSubtotalFormatted = () => { + const subtotal = useCreditNoteSubtotal(); + const { + values: { currency_code: currencyCode }, + } = useFormikContext(); + + return formattedAmount(subtotal, currencyCode, { money: true }); +}; + +/** + * Retrieves the credit note discount amount. + * @returns {number} + */ +export const useCreditNoteDiscountAmount = () => { + const { values } = useFormikContext(); + const subtotal = useCreditNoteSubtotal(); + const discount = toSafeNumber(values.discount); + + return values?.discount_type === 'percentage' + ? (discount * subtotal) / 100 + : discount; +}; + +/** + * Retrieves the credit note discount amount formatted. + * @returns {string} + */ +export const useCreditNoteDiscountAmountFormatted = () => { + const discountAmount = useCreditNoteDiscountAmount(); + const { + values: { currency_code: currencyCode }, + } = useFormikContext(); + + return formattedAmount(discountAmount, currencyCode, { money: true }); +}; + +/** + * Retrieves the credit note adjustment amount. + * @returns {number} + */ +export const useCreditNoteAdjustmentAmount = () => { + const { values } = useFormikContext(); + + return toSafeNumber(values.adjustment); +}; + +/** + * Retrieves the credit note adjustment amount formatted. + * @returns {string} + */ +export const useCreditNoteAdjustmentFormatted = () => { + const adjustmentAmount = useCreditNoteAdjustmentAmount(); + const { + values: { currency_code: currencyCode }, + } = useFormikContext(); + + return formattedAmount(adjustmentAmount, currencyCode, { money: true }); +}; + +/** + * Retrieves the credit note total. + * @returns {number} + */ +export const useCreditNoteTotal = () => { + const subtotal = useCreditNoteSubtotal(); + const discountAmount = useCreditNoteDiscountAmount(); + const adjustmentAmount = useCreditNoteAdjustmentAmount(); + + return R.compose( + R.subtract(R.__, discountAmount), + R.add(R.__, adjustmentAmount), + )(subtotal); +}; + +/** + * Retrieves the credit note total formatted. + * @returns {string} + */ +export const useCreditNoteTotalFormatted = () => { + const total = useCreditNoteTotal(); + const { + values: { currency_code: currencyCode }, + } = useFormikContext(); + + return formattedAmount(total, currencyCode, { money: true }); }; /** @@ -217,7 +297,6 @@ export const useCreditNoteIsForeignCustomer = () => { return isForeignCustomer; }; - export const useCreditNoteFormBrandingTemplatesOptions = () => { const { brandingTemplates } = useCreditNoteFormContext(); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialog.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialog.tsx deleted file mode 100644 index 6a0b832c3..000000000 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialog.tsx +++ /dev/null @@ -1,39 +0,0 @@ -// @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 deleted file mode 100644 index e77e3ee99..000000000 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialogContent.tsx +++ /dev/null @@ -1,40 +0,0 @@ -// @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 a9dbbbf39..dcc117900 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx @@ -160,7 +160,7 @@ function EstimateForm({ overflow: 'hidden', display: 'flex', flexDirection: 'column', - flex: 1 + flex: 1, })} > diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormCurrencyTag.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormCurrencyTag.tsx index c272fbebe..68036c5fd 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormCurrencyTag.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormCurrencyTag.tsx @@ -13,7 +13,6 @@ export default function EstimateFromCurrencyTag() { if (!isForeignCustomer) { return null; } - return ( diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormDialogs.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormDialogs.tsx index a50326486..aa1c165a1 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormDialogs.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormDialogs.tsx @@ -2,8 +2,6 @@ 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. @@ -27,9 +25,6 @@ export default function EstimateFormDialogs() { dialogName={'estimate-number-form'} onConfirm={handleEstimateNumberFormConfirm} /> - ); } diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormFooterRight.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormFooterRight.tsx index 93135fdd2..c3ff4d13d 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormFooterRight.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormFooterRight.tsx @@ -1,28 +1,40 @@ // @ts-nocheck import React from 'react'; import styled from 'styled-components'; +import { useFormikContext } from 'formik'; +import { T, TotalLines, TotalLine, TotalLineTextStyle } from '@/components'; import { - T, - TotalLines, - TotalLine, - TotalLineBorderStyle, - TotalLineTextStyle, -} from '@/components'; -import { useEstimateTotals } from './utils'; + useEstimateAdjustmentFormatted, + useEstimateDiscountFormatted, + useEstimateSubtotalFormatted, + useEstimateTotalFormatted, +} from './utils'; +import { AdjustmentTotalLine } from '../../Invoices/InvoiceForm/AdjustmentTotalLine'; +import { DiscountTotalLine } from '../../Invoices/InvoiceForm/DiscountTotalLine'; export function EstimateFormFooterRight() { - const { formattedSubtotal, formattedTotal } = useEstimateTotals(); + const { + values: { currency_code }, + } = useFormikContext(); + const subtotalFormatted = useEstimateSubtotalFormatted(); + const totalFormatted = useEstimateTotalFormatted(); + const discountAmountFormatted = useEstimateDiscountFormatted(); + const adjustmentAmountFormatted = useEstimateAdjustmentFormatted(); return ( } - value={formattedSubtotal} - borderStyle={TotalLineBorderStyle.None} + value={subtotalFormatted} /> + + } - value={formattedTotal} + value={totalFormatted} textStyle={TotalLineTextStyle.Bold} /> diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeader.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeader.tsx index ac62fc4da..a8d924784 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeader.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeader.tsx @@ -1,12 +1,10 @@ // @ts-nocheck -import React, { useMemo } from 'react'; +import React from 'react'; import intl from 'react-intl-universal'; -import { useFormikContext } from 'formik'; -import { x } from '@xstyled/emotion'; import EstimateFormHeaderFields from './EstimateFormHeaderFields'; -import { getEntriesTotal } from '@/containers/Entries/utils'; import { Group, PageFormBigNumber } from '@/components'; +import { useEstimateTotalFormatted } from './utils'; // Estimate form top header. function EstimateFormHeader() { @@ -29,19 +27,10 @@ function EstimateFormHeader() { * @returns {React.ReactNode} */ function EstimateFormBigTotal() { - const { - values: { entries, currency_code }, - } = useFormikContext(); - - // Calculate the total due amount of bill entries. - const totalDueAmount = useMemo(() => getEntriesTotal(entries), [entries]); + const totalFormatted = useEstimateTotalFormatted(); return ( - + ); } diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/components.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/components.tsx index 3cace61f2..9cae3ae5d 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/components.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/components.tsx @@ -6,7 +6,7 @@ import * as R from 'ramda'; import { useFormikContext } from 'formik'; import { ExchangeRateInputGroup } from '@/components'; import { useCurrentOrganization } from '@/hooks/state'; -import { useEstimateIsForeignCustomer, useEstimateTotals } from './utils'; +import { useEstimateIsForeignCustomer, useEstimateSubtotal } from './utils'; import { transactionNumber } from '@/utils'; import { useUpdateEffect } from '@/hooks'; import withSettings from '@/containers/Settings/withSettings'; @@ -102,13 +102,13 @@ export const EstimateSyncAutoExRateToForm = R.compose(withDialogActions)( // #withDialogActions openDialog, }) => { - const { total } = useEstimateTotals(); + const subtotal = useEstimateSubtotal(); const timeout = useRef(); useSyncExRateToForm({ onSynced: () => { // If the total bigger then zero show alert to the user after adjusting entries. - if (total > 0) { + if (subtotal > 0) { clearTimeout(timeout.current); timeout.current = setTimeout(() => { openDialog(DialogsName.InvoiceExchangeRateChangeNotice); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/utils.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/utils.tsx index 355c7619e..1bca08342 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/utils.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/utils.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import React from 'react'; +import React, { useMemo } from 'react'; import * as R from 'ramda'; import intl from 'react-intl-universal'; import moment from 'moment'; @@ -10,6 +10,7 @@ import { repeatValue, transformToForm, formattedAmount, + toSafeNumber, } from '@/utils'; import { useEstimateFormContext } from './EstimateFormProvider'; import { @@ -63,6 +64,9 @@ export const defaultEstimate = { entries: [...repeatValue(defaultEstimateEntry, MIN_LINES_NUMBER)], attachments: [], pdf_template_id: '', + adjustment: '', + discount: '', + discount_type: 'amount', }; const ERRORS = { @@ -208,32 +212,110 @@ export const useSetPrimaryBranchToForm = () => { }; /** - * Retreives the estimate totals. + * Retrieves the estimate subtotal. + * @returns {number} */ -export const useEstimateTotals = () => { +export const useEstimateSubtotal = () => { const { - values: { entries, currency_code: currencyCode }, + values: { entries }, } = useFormikContext(); // Retrieves the invoice entries total. - const total = React.useMemo(() => getEntriesTotal(entries), [entries]); + const subtotal = useMemo(() => getEntriesTotal(entries), [entries]); - // Retrieves the formatted total money. - const formattedTotal = React.useMemo( - () => formattedAmount(total, currencyCode), - [total, currencyCode], - ); - // Retrieves the formatted subtotal. - const formattedSubtotal = React.useMemo( - () => formattedAmount(total, currencyCode, { money: false }), - [total, currencyCode], - ); + return subtotal; +}; - return { - total, - formattedTotal, - formattedSubtotal, - }; +/** + * Retrieves the estimate subtotal formatted. + * @returns {string} + */ +export const useEstimateSubtotalFormatted = () => { + const subtotal = useEstimateSubtotal(); + const { + values: { currency_code: currencyCode }, + } = useFormikContext(); + + return formattedAmount(subtotal, currencyCode); +}; + +/** + * Retrieves the estimate discount amount. + * @returns {number} + */ +export const useEstimateDiscount = () => { + const { values } = useFormikContext(); + const subtotal = useEstimateSubtotal(); + const discount = toSafeNumber(values.discount); + + return values?.discount_type === 'percentage' + ? (subtotal * discount) / 100 + : discount; +}; + +/** + * Retrieves the estimate discount formatted. + * @returns {string} + */ +export const useEstimateDiscountFormatted = () => { + const discount = useEstimateDiscount(); + const { + values: { currency_code: currencyCode }, + } = useFormikContext(); + + return formattedAmount(discount, currencyCode); +}; + +/** + * Retrieves the estimate adjustment amount. + * @returns {number} + */ +export const useEstimateAdjustment = () => { + const { values } = useFormikContext(); + const adjustmentAmount = toSafeNumber(values.adjustment); + + return adjustmentAmount; +}; + +/** + * Retrieves the estimate adjustment formatted. + * @returns {string} + */ +export const useEstimateAdjustmentFormatted = () => { + const adjustment = useEstimateAdjustment(); + const { + values: { currency_code: currencyCode }, + } = useFormikContext(); + + return formattedAmount(adjustment, currencyCode); +}; + +/** + * Retrieves the estimate total. + * @returns {number} + */ +export const useEstimateTotal = () => { + const subtotal = useEstimateSubtotal(); + const discount = useEstimateDiscount(); + const adjustment = useEstimateAdjustment(); + + return R.compose( + R.subtract(R.__, discount), + R.add(R.__, adjustment), + )(subtotal); +}; + +/** + * Retrieves the estimate total formatted. + * @returns {string} + */ +export const useEstimateTotalFormatted = () => { + const total = useEstimateTotal(); + const { + values: { currency_code: currencyCode }, + } = useFormikContext(); + + return formattedAmount(total, currencyCode); }; /** diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog.tsx deleted file mode 100644 index 0d13e07fb..000000000 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog.tsx +++ /dev/null @@ -1,35 +0,0 @@ -// @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 deleted file mode 100644 index 2fa1c0472..000000000 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogBody.tsx +++ /dev/null @@ -1,33 +0,0 @@ -// @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 deleted file mode 100644 index 65b05a9c1..000000000 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogBoot.tsx +++ /dev/null @@ -1,48 +0,0 @@ -// @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 deleted file mode 100644 index c673f71c6..000000000 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogContent.tsx +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index a4d1324e5..000000000 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogForm.tsx +++ /dev/null @@ -1,81 +0,0 @@ -// @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, } = - 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 deleted file mode 100644 index 405c73c0f..000000000 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogFormContent.tsx +++ /dev/null @@ -1,66 +0,0 @@ -// @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 deleted file mode 100644 index bebbc8bef..000000000 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './EstimateMailDialog'; \ No newline at end of file diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMail.schema.ts b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMail.schema.ts new file mode 100644 index 000000000..acb02f429 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMail.schema.ts @@ -0,0 +1,11 @@ +import * as Yup from 'yup'; + +export const EstimateSendMailSchema = Yup.object().shape({ + subject: Yup.string().required('Subject is required'), + message: Yup.string().required('Message is required'), + to: Yup.array() + .of(Yup.string().email('Invalid email address')) + .required('To address is required'), + cc: Yup.array().of(Yup.string().email('Invalid email address')), + bcc: Yup.array().of(Yup.string().email('Invalid email address')), +}); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailBoot.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailBoot.tsx new file mode 100644 index 000000000..41473192b --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailBoot.tsx @@ -0,0 +1,55 @@ +import React, { createContext, useContext } from 'react'; +import { Spinner } from '@blueprintjs/core'; +import { useDrawerContext } from '@/components/Drawer/DrawerProvider'; +import { SaleEstimateMailStateResponse, useSaleEstimateMailState } from '@/hooks/query'; + +interface EstimateSendMailBootValues { + estimateId: number; + + estimateMailState: SaleEstimateMailStateResponse | undefined; + isEstimateMailState: boolean; +} +interface EstimateSendMailBootProps { + children: React.ReactNode; +} + +const EstimateSendMailContentBootContext = + createContext({} as EstimateSendMailBootValues); + +export const EstimateSendMailBoot = ({ + children, +}: EstimateSendMailBootProps) => { + const { + payload: { estimateId }, + } = useDrawerContext(); + + // Estimate mail options. + const { data: estimateMailState, isLoading: isEstimateMailState } = + useSaleEstimateMailState(estimateId); + + const isLoading = isEstimateMailState; + + if (isLoading) { + return ; + } + const value = { + estimateId, + + // # Estimate mail options + isEstimateMailState, + estimateMailState, + }; + + return ( + + {children} + + ); +}; +EstimateSendMailBoot.displayName = 'EstimateSendMailBoot'; + +export const useEstimateSendMailBoot = () => { + return useContext( + EstimateSendMailContentBootContext, + ); +}; diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailContent.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailContent.tsx new file mode 100644 index 000000000..6b894e118 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailContent.tsx @@ -0,0 +1,24 @@ +import { Classes } from '@blueprintjs/core'; +import { EstimateSendMailBoot } from './EstimateSendMailBoot'; +import { Stack } from '@/components'; +import { EstimateSendMailForm } from './EstimateSendMailForm'; +import { SendMailViewHeader } from '../SendMailViewDrawer/SendMailViewHeader'; +import { SendMailViewLayout } from '../SendMailViewDrawer/SendMailViewLayout'; +import { EstimateSendMailFields } from './EstimateSnedMailFields'; +import { EstimateSendMailPreviewTabs } from './EstimateSendMailPreview'; + +export function EstimateSendMailContent() { + return ( + + + + } + fields={} + preview={} + /> + + + + ); +} diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailDrawer.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailDrawer.tsx new file mode 100644 index 000000000..be9b17b81 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailDrawer.tsx @@ -0,0 +1,42 @@ +// @ts-nocheck +import React from 'react'; +import * as R from 'ramda'; +import { Drawer, DrawerSuspense } from '@/components'; +import withDrawers from '@/containers/Drawer/withDrawers'; + +const EstimateSendMailContent = React.lazy(() => + import('./EstimateSendMailContent').then((module) => ({ + default: module.EstimateSendMailContent, + })), +); + +interface EstimateSendMailDrawerProps { + name: string; + isOpen?: boolean; + payload?: any; +} + +function EstimateSendMailDrawerRoot({ + name, + + // #withDrawer + isOpen, + payload, +}: EstimateSendMailDrawerProps) { + return ( + + + + + + ); +} + +export const EstimateSendMailDrawer = R.compose(withDrawers())( + EstimateSendMailDrawerRoot, +); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailForm.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailForm.tsx new file mode 100644 index 000000000..b7ee0f592 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailForm.tsx @@ -0,0 +1,79 @@ +// @ts-nocheck +import { Form, Formik, FormikHelpers } from 'formik'; +import { css } from '@emotion/css'; +import { Intent } from '@blueprintjs/core'; +import { EstimateSendMailFormValues } from './_interfaces'; +import { EstimateSendMailSchema } from './EstimateSendMail.schema'; +import { useSendSaleEstimateMail } from '@/hooks/query'; +import { AppToaster } from '@/components'; +import { useDrawerActions } from '@/hooks/state'; +import { useDrawerContext } from '@/components/Drawer/DrawerProvider'; +import { transformToForm } from '@/utils'; +import { useEstimateSendMailBoot } from './EstimateSendMailBoot'; + +const initialValues: EstimateSendMailFormValues = { + subject: '', + message: '', + to: [], + cc: [], + bcc: [], + attachPdf: true, +}; + +interface EstimateSendMailFormProps { + children: React.ReactNode; +} + +export function EstimateSendMailForm({ children }: EstimateSendMailFormProps) { + const { mutateAsync: sendEstimateMail } = useSendSaleEstimateMail(); + const { estimateId, estimateMailState } = useEstimateSendMailBoot(); + + const { name } = useDrawerContext(); + const { closeDrawer } = useDrawerActions(); + + const _initialValues: EstimateSendMailFormValues = { + ...initialValues, + ...transformToForm(estimateMailState, initialValues), + }; + const handleSubmit = ( + values: EstimateSendMailFormValues, + { setSubmitting }: FormikHelpers, + ) => { + setSubmitting(true); + sendEstimateMail([estimateId, values]) + .then(() => { + AppToaster.show({ + message: 'The invoice mail has been sent to the customer.', + intent: Intent.SUCCESS, + }); + setSubmitting(false); + closeDrawer(name); + }) + .catch((error) => { + setSubmitting(false); + AppToaster.show({ + message: 'Something went wrong!', + intent: Intent.SUCCESS, + }); + }); + }; + + return ( + +
+ {children} +
+
+ ); +} diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailPreview.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailPreview.tsx new file mode 100644 index 000000000..917c8b551 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailPreview.tsx @@ -0,0 +1,40 @@ +import { lazy } from 'react'; +import { Suspense } from 'react'; +import { Tab } from '@blueprintjs/core'; +import { SendMailViewPreviewTabs } from '../SendMailViewDrawer/SendMailViewPreviewTabs'; + +const EstimateSendPdfPreviewConnected = lazy(() => + import('./EstimateSendPdfPreviewConnected').then((module) => ({ + default: module.EstimateSendPdfPreviewConnected, + })), +); +const EstimateSendMailReceiptPreview = lazy(() => + import('./EstimateSendMailReceiptPreview').then((module) => ({ + default: module.EstimateSendMailReceiptPreview, + })), +); + +export function EstimateSendMailPreviewTabs() { + return ( + + + + + } + /> + + + + } + /> + + ); +} diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailPreviewHeader.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailPreviewHeader.tsx new file mode 100644 index 000000000..65584e54e --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailPreviewHeader.tsx @@ -0,0 +1,23 @@ +import { useFormikContext } from 'formik'; +import { SendViewPreviewHeader } from '../SendMailViewDrawer/SendMailViewPreviewHeader'; +import { useEstimateSendMailBoot } from './EstimateSendMailBoot'; +import { useSendEstimateMailSubject } from './hooks'; +import { EstimateSendMailFormValues } from './_interfaces'; + +export function EstimateSendMailPreviewHeader() { + const subject = useSendEstimateMailSubject(); + const { estimateMailState } = useEstimateSendMailBoot(); + const { + values: { to, from }, + } = useFormikContext(); + + return ( + + ); +} diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailReceipt.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailReceipt.tsx new file mode 100644 index 000000000..0f97aebb6 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailReceipt.tsx @@ -0,0 +1,203 @@ +import { x } from '@xstyled/emotion'; +import { isEmpty } from 'lodash'; +import { Group, Stack } from '@/components'; +import { + SendMailReceipt, + SendMailReceiptProps, +} from '../SendMailViewDrawer/SendMailViewReceiptPreview'; + +export interface EstimateSendMailReceiptProps extends SendMailReceiptProps { + // # Company name. + companyLogoUri?: string; + companyName: string; + + // # Estimate number. + estimateNumberLabel?: string; + estimateNumber: string; + + // # Discount + discount?: string; + discountLabel?: string; + + // # Adjustment + adjustment?: string; + adjsutmentLabel?: string; + + // # Total. + total: string; + totalLabel?: string; + + // # Expiration date. + expirationDateLabel?: string; + expirationDate: string; + + // # Message. + message: string; + + // # Estimate items. + items?: Array<{ label: string; total: string; quantity: string | number }>; + + // # Subtotal + subtotalLabel?: string; + subtotal: string; + + // # View estimate button + showViewEstimateButton?: boolean; + viewEstimateButtonLabel?: string; + viewEstimateButtonOnClick?: () => void; +} + +export function EstimateSendMailReceipt({ + // # Company name. + companyLogoUri, + companyName, + + // # Estimate number. + estimateNumberLabel = 'Estimate #', + estimateNumber, + + // # Expiration date. + expirationDateLabel = 'Expiration Date', + expirationDate, + + // # Message + message, + + // # Items + items, + + // # Subtotal + subtotal, + subtotalLabel = 'Subtotal', + + // # Discount + discount, + discountLabel = 'Discount', + + // # Adjustment + adjustment, + adjsutmentLabel = 'Adjustment', + + // # Total. + total, + totalLabel = 'Total', + + // # View estimate button + showViewEstimateButton = true, + viewEstimateButtonLabel = 'View Estimate', + viewEstimateButtonOnClick, + + ...props +}: EstimateSendMailReceiptProps) { + return ( + + + {companyLogoUri && } + + + + {companyName} + + + + {total} + + + + {estimateNumberLabel} {estimateNumber} + + + + {expirationDateLabel} {expirationDate} + + + + + + {message} + + + {showViewEstimateButton && ( + + {viewEstimateButtonLabel} + + )} + + + {items?.map((item, key) => ( + + {item.label} + + {item.quantity} x {item.total} + + + ))} + + + {subtotalLabel} + + {subtotal} + + + + {!isEmpty(discount) && ( + + {discountLabel} + {discount} + + )} + + {!isEmpty(adjustment) && ( + + {adjsutmentLabel} + {adjustment} + + )} + + + {totalLabel} + + {total} + + + + + ); +} diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailReceiptPreview.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailReceiptPreview.tsx new file mode 100644 index 000000000..8ca38db20 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendMailReceiptPreview.tsx @@ -0,0 +1,33 @@ +import { css } from '@emotion/css'; +import { ComponentType } from 'react'; +import { + EstimateSendMailReceipt, + EstimateSendMailReceiptProps, +} from './EstimateSendMailReceipt'; +import { EstimateSendMailPreviewHeader } from './EstimateSendMailPreviewHeader'; +import { withEstimateMailReceiptPreviewProps } from './withEstimateMailReceiptPreviewProps'; +import { Stack } from '@/components'; + +const estimatePreviewCss = css` + margin: 0 auto; + border-radius: 5px !important; + transform: scale(0.9); + transform-origin: top; + box-shadow: 0 10px 15px rgba(0, 0, 0, 0.05) !important; +`; + +export const EstimateSendMailReceiptPreview = () => { + return ( + + + + + + + + ); +}; + +const EstimateSendMailReceiptConnected = withEstimateMailReceiptPreviewProps( + EstimateSendMailReceipt, +) as ComponentType>; diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendPdfPreviewConnected.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendPdfPreviewConnected.tsx new file mode 100644 index 000000000..eb57273a0 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSendPdfPreviewConnected.tsx @@ -0,0 +1,30 @@ +import { Spinner } from '@blueprintjs/core'; +import { Stack } from '@/components'; +import { useGetSaleEstimateHtml } from '@/hooks/query'; +import { EstimateSendMailPreviewHeader } from './EstimateSendMailPreviewHeader'; +import { SendMailViewPreviewPdfIframe } from '../SendMailViewDrawer/SendMailViewPreviewPdfIframe'; +import { useDrawerContext } from '@/components/Drawer/DrawerProvider'; + +export function EstimateSendPdfPreviewConnected() { + return ( + + + + + + + + ); +} + +function EstimateSendPdfPreviewIframe() { + const { payload } = useDrawerContext(); + const { data, isLoading } = useGetSaleEstimateHtml(payload?.estimateId); + + if (isLoading && data) { + return ; + } + const iframeSrcDoc = data?.htmlContent; + + return ; +} diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSnedMailFields.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSnedMailFields.tsx new file mode 100644 index 000000000..6cd0672b8 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/EstimateSnedMailFields.tsx @@ -0,0 +1,76 @@ +import { useFormikContext } from 'formik'; +import { Button, Intent } from '@blueprintjs/core'; +import { FCheckbox, FFormGroup, FInputGroup, Group, Stack } from '@/components'; +import { SendMailViewToAddressField } from '../SendMailViewDrawer/SendMailViewToAddressField'; +import { SendMailViewMessageField } from '../SendMailViewDrawer/SendMailViewMessageField'; +import { useDrawerContext } from '@/components/Drawer/DrawerProvider'; +import { useDrawerActions } from '@/hooks/state'; +import { useSendEstimateFormatArgsOptions } from './hooks'; +import { useSendMailItems } from '../SendMailViewDrawer/hooks'; + + +export function EstimateSendMailFields() { + const argOptions = useSendEstimateFormatArgsOptions(); + const items = useSendMailItems(); + + return ( + + + + + + + + + + + + + + + + + ); +} + +function EstimateSendMailFooter() { + const { isSubmitting } = useFormikContext(); + const { name } = useDrawerContext(); + const { closeDrawer } = useDrawerActions(); + + const handleClose = () => { + closeDrawer(name); + }; + + return ( + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/_constants.ts b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/_constants.ts new file mode 100644 index 000000000..941bb5a73 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/_constants.ts @@ -0,0 +1,23 @@ +export const defaultEstimateMailReceiptProps = { + companyName: 'Bigcapital Technology, Inc.', + companyLogoUri: ' ', + + total: '$1,000.00', + subtotal: '$1,000.00', + estimateNumber: 'INV-0001', + expirationDate: '2 Oct 2024', + dueAmount: '$1,000.00', + items: [{ label: 'Web development', total: '$1000.00', quantity: 1 }], + message: `Hi Ahmed Bouhuolia, + +Here's invoice # INV-00002 for $738.30 + +The amount outstanding of $737.30 is due on 01 Feb 2023. + +From your online payment page you can print a PDF or view your outstanding bills. + +If you have any questions, please let us know. + +Thanks, +Bigcapital`, +}; diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/_interfaces.ts b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/_interfaces.ts new file mode 100644 index 000000000..f8ba75639 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/_interfaces.ts @@ -0,0 +1,5 @@ +import { SendMailViewFormValues } from '../SendMailViewDrawer/_types'; + +export interface EstimateSendMailFormValues extends SendMailViewFormValues { + attachPdf?: boolean; +} diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/hooks.ts b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/hooks.ts new file mode 100644 index 000000000..27d927ccc --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/hooks.ts @@ -0,0 +1,55 @@ +import { useMemo } from 'react'; +import { useFormikContext } from 'formik'; +import { SelectOptionProps } from '@blueprintjs-formik/select'; +import { useEstimateSendMailBoot } from './EstimateSendMailBoot'; +import { + formatMailMessage, + transformEmailArgs, + transformFormatArgsToOptions, +} from '../SendMailViewDrawer/hooks'; +import { EstimateSendMailFormValues } from './_interfaces'; + +/** + * Retrieves the mail format arguments of estimate mail. + * @returns {Record} + */ +export const useSendEstimateMailFormatArgs = (): Record => { + const { estimateMailState } = useEstimateSendMailBoot(); + + return useMemo(() => { + return transformEmailArgs(estimateMailState?.formatArgs || {}); + }, [estimateMailState]); +}; + +/** + * Retrieves the formatted estimate subject. + * @returns {string} + */ +export const useSendEstimateMailSubject = (): string => { + const { values } = useFormikContext(); + const formatArgs = useSendEstimateMailFormatArgs(); + + return formatMailMessage(values?.subject, formatArgs); +}; + +/** + * Retrieves the estimate format options. + * @returns {Array} + */ +export const useSendEstimateFormatArgsOptions = + (): Array => { + const formatArgs = useSendEstimateMailFormatArgs(); + + return transformFormatArgsToOptions(formatArgs); + }; + +/** + * Retrieves the formatted estimate message. + * @returns {string} + */ +export const useSendEstimateMailMessage = (): string => { + const { values } = useFormikContext(); + const formatArgs = useSendEstimateMailFormatArgs(); + + return formatMailMessage(values?.message, formatArgs); +}; diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/index.ts b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/index.ts new file mode 100644 index 000000000..e366aca3e --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/index.ts @@ -0,0 +1 @@ +export * from './EstimateSendMailDrawer'; diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/withEstimateMailReceiptPreviewProps.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/withEstimateMailReceiptPreviewProps.tsx new file mode 100644 index 000000000..e4a850c66 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateSendMailDrawer/withEstimateMailReceiptPreviewProps.tsx @@ -0,0 +1,46 @@ +import { ComponentType, useMemo } from 'react'; +import { defaultEstimateMailReceiptProps } from './_constants'; +import { useEstimateSendMailBoot } from './EstimateSendMailBoot'; +import { useSendEstimateMailMessage } from './hooks'; +import { EstimateSendMailReceiptProps } from './EstimateSendMailReceipt'; + +/** + * Injects props from estimate mail state into the `EstimateSendMailReceipt` component. + */ +export const withEstimateMailReceiptPreviewProps = < + P extends EstimateSendMailReceiptProps, +>( + WrappedComponent: ComponentType

, +) => { + return function WithInvoiceMailReceiptPreviewProps(props: P) { + const { estimateMailState } = useEstimateSendMailBoot(); + const message = useSendEstimateMailMessage(); + + const items = useMemo( + () => + estimateMailState?.entries?.map((entry: any) => ({ + quantity: entry.quantity, + total: entry.totalFormatted, + label: entry.name, + })), + [estimateMailState?.entries], + ); + + const mailReceiptPreviewProps = { + ...defaultEstimateMailReceiptProps, + companyName: estimateMailState?.companyName, + companyLogoUri: estimateMailState?.companyLogoUri, + primaryColor: estimateMailState?.primaryColor, + total: estimateMailState?.totalFormatted, + expirationDate: estimateMailState?.expirationDateFormatted, + estimateNumber: estimateMailState?.estimateNumber, + estimateDate: estimateMailState?.estimateDateFormatted, + subtotal: estimateMailState?.subtotalFormatted, + discount: estimateMailState?.discountAmountFormatted, + adjustment: estimateMailState?.adjustmentFormatted, + items, + message, + }; + return ; + }; +}; diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimatesAlerts.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimatesAlerts.tsx index d5931793c..730ad3043 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimatesAlerts.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimatesAlerts.tsx @@ -18,20 +18,8 @@ const EstimateRejectAlert = React.lazy( * Estimates alert. */ export default [ - { - name: 'estimate-delete', - component: EstimateDeleteAlert, - }, - { - name: 'estimate-deliver', - component: EstimateDeliveredAlert, - }, - { - name: 'estimate-Approve', - component: EstimateApproveAlert, - }, - { - name: 'estimate-reject', - component: EstimateRejectAlert, - }, + { name: 'estimate-delete', component: EstimateDeleteAlert }, + { name: 'estimate-deliver', component: EstimateDeliveredAlert }, + { name: 'estimate-Approve', component: EstimateApproveAlert }, + { name: 'estimate-reject', component: EstimateRejectAlert }, ]; diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/EstimatesDataTable.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/EstimatesDataTable.tsx index 4cc70e498..33cd4c2a1 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/EstimatesDataTable.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimatesLanding/EstimatesDataTable.tsx @@ -107,7 +107,7 @@ function EstimatesDataTable({ // Handle mail send estimate. const handleMailSendEstimate = ({ id }) => { - openDialog(DialogsName.EstimateMail, { estimateId: id }); + openDrawer(DRAWERS.ESTIMATE_SEND_MAIL, { estimateId: id }); } // Local storage memorizing columns widths. diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceSendMailDrawer/InvoiceSendMailHeader.tsx b/packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewHeader.tsx similarity index 91% rename from packages/webapp/src/containers/Sales/Invoices/InvoiceSendMailDrawer/InvoiceSendMailHeader.tsx rename to packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewHeader.tsx index 25d3ab008..98a9c6584 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceSendMailDrawer/InvoiceSendMailHeader.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewHeader.tsx @@ -4,16 +4,16 @@ import { Group, Icon } from '@/components'; import { useDrawerContext } from '@/components/Drawer/DrawerProvider'; import { useDrawerActions } from '@/hooks/state'; -interface ElementCustomizeHeaderProps { +interface SendMailViewHeaderProps { label?: string; children?: React.ReactNode; closeButton?: boolean; } -export function InvoiceSendMailHeader({ +export function SendMailViewHeader({ label, closeButton = true, -}: ElementCustomizeHeaderProps) { +}: SendMailViewHeaderProps) { const { name } = useDrawerContext(); const { closeDrawer } = useDrawerActions(); diff --git a/packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewLayout.tsx b/packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewLayout.tsx new file mode 100644 index 000000000..4615f11fc --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewLayout.tsx @@ -0,0 +1,37 @@ +import { Group, Stack } from '@/components'; +import React from 'react'; + +interface SendMailViewLayoutProps { + header?: React.ReactNode; + fields?: React.ReactNode; + preview?: React.ReactNode; +} + +export function SendMailViewLayout({ + header, + fields, + preview, +}: SendMailViewLayoutProps) { + return ( + + {header} + + + + {fields} + + + + {preview} + + + + ); +} diff --git a/packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewMessageField.tsx b/packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewMessageField.tsx new file mode 100644 index 000000000..c9df847d7 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewMessageField.tsx @@ -0,0 +1,108 @@ +// @ts-nocheck +import { useCallback, useRef } from 'react'; +import { useFormikContext } from 'formik'; +import { Button, Icon, Position } from '@blueprintjs/core'; +import { SelectOptionProps } from '@blueprintjs-formik/select'; +import { FormGroupProps, TextAreaProps } from '@blueprintjs-formik/core'; +import { css } from '@emotion/css'; +import { FFormGroup, FSelect, FTextArea, Group, Stack } from '@/components'; +import { InvoiceSendMailFormValues } from '../../Invoices/InvoiceSendMailDrawer/_types'; + +interface SendMailViewMessageFieldProps { + argsOptions?: Array; + formGroupProps?: Partial; + selectProps?: Partial; + textareaProps?: Partial; +} + +export function SendMailViewMessageField({ + argsOptions, + formGroupProps, + textareaProps, +}: SendMailViewMessageFieldProps) { + const textareaRef = useRef(null); + const { setFieldValue } = useFormikContext(); + + const handleTextareaChange = useCallback( + (item: SelectOptionProps) => { + const textarea = textareaRef.current; + if (!textarea) return; + + const { selectionStart, selectionEnd, value: text } = textarea; + const insertText = `{${item.value}}`; + const message = + text.substring(0, selectionStart) + + insertText + + text.substring(selectionEnd); + + setFieldValue('message', message); + + // Move the cursor to the end of the inserted text + setTimeout(() => { + textarea.selectionStart = textarea.selectionEnd = + selectionStart + insertText.length; + textarea.focus(); + }, 0); + }, + [setFieldValue], + ); + + const handleTagInputKeyDown = (e: React.KeyboardEvent) => { + // Prevent the form from submitting when the user presses the Enter key + if (e.key === 'Enter') { + e.preventDefault(); + } + }; + + return ( + + + + ( + + )} + fill={false} + fastField + /> + + + + + + ); +} diff --git a/packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewPreview.tsx b/packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewPreview.tsx new file mode 100644 index 000000000..510849abc --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewPreview.tsx @@ -0,0 +1,9 @@ + + + +export function SendMailViewPreview() { + + return ( +

asdasd

+ ) +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewPreviewHeader.tsx b/packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewPreviewHeader.tsx new file mode 100644 index 000000000..c33c9fb68 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewPreviewHeader.tsx @@ -0,0 +1,76 @@ +import { useMemo } from 'react'; +import { x } from '@xstyled/emotion'; +import { Box, Group, Stack } from '@/components'; + +interface SendViewPreviewHeaderProps { + companyName?: string; + customerName?: string; + subject: string; + from?: Array; + to?: Array; +} + +export function SendViewPreviewHeader({ + companyName, + subject, + customerName, + from, + to, +}: SendViewPreviewHeaderProps) { + const formatedFromAddresses = useMemo( + () => formatAddresses(from || []), + [from], + ); + const formattedToAddresses = useMemo(() => formatAddresses(to || []), [to]); + + return ( + + + + {subject} + + + + + + + A + + + + + {companyName} + {formatedFromAddresses} + + + + Send to: {customerName} {formattedToAddresses}; + + + + + + ); +} + +const formatAddresses = (addresses: Array) => + addresses?.map((email) => '<' + email + '>').join(' '); \ No newline at end of file diff --git a/packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewPreviewPdfIframe.tsx b/packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewPreviewPdfIframe.tsx new file mode 100644 index 000000000..441d46e10 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/SendMailViewDrawer/SendMailViewPreviewPdfIframe.tsx @@ -0,0 +1,27 @@ +import { css } from '@emotion/css'; +import clsx from 'classnames'; + +interface SendMailViewPreviewPdfIframeProps + extends React.IframeHTMLAttributes {} + +export const SendMailViewPreviewPdfIframe = ({ + ...props +}: SendMailViewPreviewPdfIframeProps) => { + return ( +