From e787d05f662531153f846b7f1c62a60131179b9c Mon Sep 17 00:00:00 2001 From: Saqib Date: Thu, 15 Apr 2021 18:57:08 +0530 Subject: [PATCH] fix(e-invoicing): validations & tax calculation fixes (#25315) * fix: GST on freight charge in e-invoicing * fix: cannot fetch e invoice settings * fix: address validations & cancel eway bill dialog * fix: except einvoice loading error seperately * fix: sider * fix: import format_date * fix: imports * fix: test * fix: test * fix: test * fix: validate total condition * feat: add company link to e-invoice settings * fix: remove extra condition * fix: test Co-authored-by: Deepesh Garg <42651287+deepeshgarg007@users.noreply.github.com> --- .../sales_invoice/test_sales_invoice.py | 15 +- .../test_tax_withholding_category.py | 41 -- .../shopify_settings/test_shopify_settings.py | 3 +- erpnext/hooks.py | 2 +- .../compensatory_leave_request.py | 2 +- erpnext/patches.txt | 2 + .../add_company_link_to_einvoice_settings.py | 16 + .../v12_0/create_taxable_value_field.py | 18 + .../e_invoice_user/e_invoice_user.json | 11 +- erpnext/regional/india/e_invoice/einvoice.js | 55 +-- erpnext/regional/india/e_invoice/utils.py | 382 +++++++++++------- erpnext/regional/india/setup.py | 5 +- erpnext/regional/india/utils.py | 47 ++- 13 files changed, 374 insertions(+), 225 deletions(-) create mode 100644 erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py create mode 100644 erpnext/patches/v12_0/create_taxable_value_field.py diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index d3e85ead446..a5efe546dda 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -1860,7 +1860,17 @@ class TestSalesInvoice(unittest.TestCase): def test_einvoice_submission_without_irn(self): # init - frappe.db.set_value('E Invoice Settings', 'E Invoice Settings', 'enable', 1) + einvoice_settings = frappe.get_doc('E Invoice Settings') + einvoice_settings.enable = 1 + einvoice_settings.applicable_from = nowdate() + einvoice_settings.append('credentials', { + 'company': '_Test Company', + 'gstin': '27AAECE4835E1ZR', + 'username': 'test', + 'password': 'test' + }) + einvoice_settings.save() + country = frappe.flags.country frappe.flags.country = 'India' @@ -1871,7 +1881,8 @@ class TestSalesInvoice(unittest.TestCase): si.submit() # reset - frappe.db.set_value('E Invoice Settings', 'E Invoice Settings', 'enable', 0) + einvoice_settings = frappe.get_doc('E Invoice Settings') + einvoice_settings.enable = 0 frappe.flags.country = country def test_einvoice_json(self): diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py index a0b0cbb9956..a0b02bccf10 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py @@ -83,47 +83,6 @@ class TestTaxWithholdingCategory(unittest.TestCase): for d in invoices: d.cancel() - def test_single_threshold_tds_with_previous_vouchers(self): - invoices = [] - frappe.db.set_value("Supplier", "Test TDS Supplier2", "tax_withholding_category", "Single Threshold TDS") - pi = create_purchase_invoice(supplier="Test TDS Supplier2") - pi.submit() - invoices.append(pi) - - pi = create_purchase_invoice(supplier="Test TDS Supplier2") - pi.submit() - invoices.append(pi) - - self.assertEqual(pi.taxes_and_charges_deducted, 2000) - self.assertEqual(pi.grand_total, 8000) - - # delete invoices to avoid clashing - for d in invoices: - d.cancel() - - def test_single_threshold_tds_with_previous_vouchers_and_no_tds(self): - invoices = [] - frappe.db.set_value("Supplier", "Test TDS Supplier2", "tax_withholding_category", "Single Threshold TDS") - pi = create_purchase_invoice(supplier="Test TDS Supplier2") - pi.submit() - invoices.append(pi) - - # TDS not applied - pi = create_purchase_invoice(supplier="Test TDS Supplier2", do_not_apply_tds=True) - pi.submit() - invoices.append(pi) - - pi = create_purchase_invoice(supplier="Test TDS Supplier2") - pi.submit() - invoices.append(pi) - - self.assertEqual(pi.taxes_and_charges_deducted, 2000) - self.assertEqual(pi.grand_total, 8000) - - # delete invoices to avoid clashing - for d in invoices: - d.cancel() - def create_purchase_invoice(**args): # return sales invoice doc object item = frappe.get_doc('Item', {'item_name': 'TDS Item'}) diff --git a/erpnext/erpnext_integrations/doctype/shopify_settings/test_shopify_settings.py b/erpnext/erpnext_integrations/doctype/shopify_settings/test_shopify_settings.py index 64ef3dc0859..58e64e83bf8 100644 --- a/erpnext/erpnext_integrations/doctype/shopify_settings/test_shopify_settings.py +++ b/erpnext/erpnext_integrations/doctype/shopify_settings/test_shopify_settings.py @@ -17,8 +17,7 @@ class ShopifySettings(unittest.TestCase): frappe.set_user("Administrator") # use the fixture data - import_doc(path=frappe.get_app_path("erpnext", "erpnext_integrations/doctype/shopify_settings/test_data/custom_field.json"), - ignore_links=True, overwrite=True) + import_doc(path=frappe.get_app_path("erpnext", "erpnext_integrations/doctype/shopify_settings/test_data/custom_field.json")) frappe.reload_doctype("Customer") frappe.reload_doctype("Sales Order") diff --git a/erpnext/hooks.py b/erpnext/hooks.py index c1947d91c7c..56bffdd5637 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -246,7 +246,7 @@ doc_events = { "on_submit": ["erpnext.regional.create_transaction_log", "erpnext.regional.italy.utils.sales_invoice_on_submit"], "on_cancel": "erpnext.regional.italy.utils.sales_invoice_on_cancel", "on_trash": "erpnext.regional.check_deletion_permission", - "validate": ["erpnext.regional.india.utils.set_transporter_address", "erpnext.regional.india.utils.validate_document_name"] + "validate": ["erpnext.regional.india.utils.set_transporter_address", "erpnext.regional.india.utils.update_taxable_values", "erpnext.regional.india.utils.validate_document_name"] }, "Purchase Invoice": { "validate": "erpnext.regional.india.utils.update_grand_total_for_rcm" diff --git a/erpnext/hr/doctype/compensatory_leave_request/compensatory_leave_request.py b/erpnext/hr/doctype/compensatory_leave_request/compensatory_leave_request.py index aa5a67f40c7..f11f08462ad 100644 --- a/erpnext/hr/doctype/compensatory_leave_request/compensatory_leave_request.py +++ b/erpnext/hr/doctype/compensatory_leave_request/compensatory_leave_request.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe from frappe import _ -from frappe.utils import date_diff, add_days, getdate, cint, format_date +from frappe.utils import date_diff, add_days, getdate, cint, formatdate as format_date from frappe.model.document import Document from erpnext.hr.utils import validate_dates, validate_overlap, get_leave_period, \ get_holidays_for_employee, create_additional_leave_ledger_entry diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 6b6e2ecdf54..28349f93fff 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -681,4 +681,6 @@ erpnext.patches.v12_0.update_payment_entry_status erpnext.patches.v12_0.add_transporter_address_field #2020-10-27 erpnext.patches.v12_0.setup_einvoice_fields #2020-12-02 erpnext.patches.v12_0.add_state_code_for_ladakh +erpnext.patches.v12_0.create_taxable_value_field erpnext.patches.v12_0.purchase_receipt_status +erpnext.patches.v12_0.add_company_link_to_einvoice_settings diff --git a/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py b/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py new file mode 100644 index 00000000000..b6bd5fa311c --- /dev/null +++ b/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals +import frappe + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company or not frappe.db.count('E Invoice User'): + return + + frappe.reload_doc("regional", "doctype", "e_invoice_user") + for creds in frappe.db.get_all('E Invoice User', fields=['name', 'gstin']): + company_name = frappe.db.sql(""" + select dl.link_name from `tabAddress` a, `tabDynamic Link` dl + where a.gstin = %s and dl.parent = a.name and dl.link_doctype = 'Company' + """, (creds.get('gstin'))) + if company_name and len(company_name) > 0: + frappe.db.set_value('E Invoice User', creds.get('name'), 'company', company_name[0][0]) \ No newline at end of file diff --git a/erpnext/patches/v12_0/create_taxable_value_field.py b/erpnext/patches/v12_0/create_taxable_value_field.py new file mode 100644 index 00000000000..a0c9fcf4cbe --- /dev/null +++ b/erpnext/patches/v12_0/create_taxable_value_field.py @@ -0,0 +1,18 @@ +from __future__ import unicode_literals +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + custom_fields = { + 'Sales Invoice Item': [ + dict(fieldname='taxable_value', label='Taxable Value', + fieldtype='Currency', insert_after='base_net_amount', hidden=1, options="Company:company:default_currency", + print_hide=1) + ] + } + + create_custom_fields(custom_fields, update=True) \ No newline at end of file diff --git a/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json index dd9d99773a3..a65b1ca7ca8 100644 --- a/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json +++ b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json @@ -5,6 +5,7 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "company", "gstin", "username", "password" @@ -30,12 +31,20 @@ "in_list_view": 1, "label": "Password", "reqd": 1 + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-12-22 15:10:53.466205", + "modified": "2021-03-22 12:16:56.365616", "modified_by": "Administrator", "module": "Regional", "name": "E Invoice User", diff --git a/erpnext/regional/india/e_invoice/einvoice.js b/erpnext/regional/india/e_invoice/einvoice.js index 9fa1334840d..cda732b0fb7 100644 --- a/erpnext/regional/india/e_invoice/einvoice.js +++ b/erpnext/regional/india/e_invoice/einvoice.js @@ -1,12 +1,13 @@ erpnext.setup_einvoice_actions = (doctype) => { frappe.ui.form.on(doctype, { - refresh(frm) { - const einvoicing_enabled = frappe.db.get_value("E Invoice Settings", "E Invoice Settings", "enable"); - const supply_type = frm.doc.gst_category; - const valid_supply_type = ['Registered Regular', 'SEZ', 'Overseas', 'Deemed Export'].includes(supply_type); - const company_transaction = frm.doc.billing_address_gstin == frm.doc.company_gstin; + async refresh(frm) { + const res = await frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.validate_eligibility', + args: { doc: frm.doc } + }); + const invoice_eligible = res.message; - if (!einvoicing_enabled || !valid_supply_type || company_transaction) return; + if (!invoice_eligible) return; const { doctype, irn, irn_cancelled, ewaybill, eway_bill_cancelled, name, __unsaved } = frm.doc; @@ -113,45 +114,25 @@ erpnext.setup_einvoice_actions = (doctype) => { } if (irn && ewaybill && !irn_cancelled && !eway_bill_cancelled) { - const fields = [ - { - "label": "Reason", - "fieldname": "reason", - "fieldtype": "Select", - "reqd": 1, - "default": "1-Duplicate", - "options": ["1-Duplicate", "2-Data Entry Error", "3-Order Cancelled", "4-Other"] - }, - { - "label": "Remark", - "fieldname": "remark", - "fieldtype": "Data", - "reqd": 1 - } - ]; const action = () => { - const d = new frappe.ui.Dialog({ - title: __('Cancel E-Way Bill'), - fields: fields, + let message = __('Cancellation of e-way bill is currently not supported. '); + message += '

'; + message += __('You must first use the portal to cancel the e-way bill and then update the cancelled status in the ERPNext system.'); + + frappe.msgprint({ + title: __('Update E-Way Bill Cancelled Status?'), + message: message, + indicator: 'orange', primary_action: function() { - const data = d.get_values(); frappe.call({ method: 'erpnext.regional.india.e_invoice.utils.cancel_eway_bill', - args: { - doctype, - docname: name, - eway_bill: ewaybill, - reason: data.reason.split('-')[0], - remark: data.remark - }, + args: { doctype, docname: name }, freeze: true, - callback: () => frm.reload_doc() || d.hide(), - error: () => d.hide() + callback: () => frm.reload_doc() }); }, - primary_action_label: __('Submit') + primary_action_label: __('Yes') }); - d.show(); }; add_custom_button(__("Cancel E-Way Bill"), action); } diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py index dd130b69fff..ed7e28a1b0a 100644 --- a/erpnext/regional/india/e_invoice/utils.py +++ b/erpnext/regional/india/e_invoice/utils.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals import os import re +import six import jwt import sys import json @@ -16,16 +17,38 @@ from frappe import _, bold from pyqrcode import create as qrcreate from frappe.integrations.utils import make_post_request, make_get_request from erpnext.regional.india.utils import get_gst_accounts, get_place_of_supply -from frappe.utils.data import cstr, cint, formatdate as format_date, flt, time_diff_in_seconds, now_datetime, add_to_date, get_link_to_form +from frappe.utils.data import cstr, cint, formatdate as format_date, flt, time_diff_in_seconds, now_datetime, add_to_date, getdate, get_link_to_form -def validate_einvoice_fields(doc): - einvoicing_enabled = cint(frappe.db.get_value('E Invoice Settings', 'E Invoice Settings', 'enable')) - invalid_doctype = doc.doctype != 'Sales Invoice' +@frappe.whitelist() +def validate_eligibility(doc): + if isinstance(doc, six.string_types): + doc = json.loads(doc) + + invalid_doctype = doc.get('doctype') != 'Sales Invoice' + if invalid_doctype: + return False + + einvoicing_enabled = cint(frappe.db.get_single_value('E Invoice Settings', 'enable')) + if not einvoicing_enabled: + return False + + if getdate(doc.get('posting_date')) < getdate('2021-04-01'): + return False + + invalid_company = not frappe.db.get_value('E Invoice User', { 'company': doc.get('company') }) invalid_supply_type = doc.get('gst_category') not in ['Registered Regular', 'SEZ', 'Overseas', 'Deemed Export'] company_transaction = doc.get('billing_address_gstin') == doc.get('company_gstin') no_taxes_applied = not doc.get('taxes') - if not einvoicing_enabled or invalid_doctype or invalid_supply_type or company_transaction or no_taxes_applied: + if invalid_company or invalid_supply_type or company_transaction or no_taxes_applied: + return False + + return True + +def validate_einvoice_fields(doc): + invoice_eligible = validate_eligibility(doc) + + if not invoice_eligible: return if doc.docstatus == 0 and doc._action == 'save': @@ -86,36 +109,37 @@ def get_doc_details(invoice): invoice_date=invoice_date )) -def get_party_details(address_name, company_address=None, billing_address=None, shipping_address=None): - d = frappe.get_all('Address', filters={'name': address_name}, fields=['*'])[0] - - if ((not d.gstin and not shipping_address) - or not d.city - or not d.pincode - or not d.address_title - or not d.address_line1 - or not d.gst_state_number): +def validate_address_fields(address, is_shipping_address): + if ((not address.gstin and not is_shipping_address) + or not address.city + or not address.pincode + or not address.address_title + or not address.address_line1 + or not address.gst_state_number): frappe.throw( - msg=_('Address lines, city, pincode, gstin is mandatory for address {}. Please set them and try again.').format( - get_link_to_form('Address', address_name) - ), + msg=_('Address Lines, City, Pincode, GSTIN are mandatory for address {}. Please set them and try again.').format(address.name), title=_('Missing Address Fields') ) - if d.gst_state_number == 97: +def get_party_details(address_name, is_shipping_address=False): + addr = frappe.get_doc('Address', address_name) + + validate_address_fields(addr, is_shipping_address) + + if addr.gst_state_number == 97: # according to einvoice standard - pincode = 999999 + addr.pincode = 999999 party_address_details = frappe._dict(dict( - legal_name=d.address_title, - location=d.city, pincode=d.pincode, - state_code=d.gst_state_number, - address_line1=d.address_line1, - address_line2=d.address_line2 + legal_name=sanitize_for_json(addr.address_title), + location=sanitize_for_json(addr.city), + pincode=addr.pincode, gstin=addr.gstin, + state_code=addr.gst_state_number, + address_line1=sanitize_for_json(addr.address_line1), + address_line2=sanitize_for_json(addr.address_line2) )) - if d.gstin: - party_address_details.gstin = d.gstin + return party_address_details def get_gstin_details(gstin): @@ -166,10 +190,15 @@ def get_item_list(invoice): item.description = json.dumps(d.item_name)[1:-1] item.qty = abs(item.qty) - item.discount_amount = 0 - item.unit_rate = abs(item.base_net_amount / item.qty) - item.gross_amount = abs(item.base_net_amount) - item.taxable_value = abs(item.base_net_amount) + + if invoice.apply_discount_on == 'Net Total' and invoice.discount_amount: + item.discount_amount = abs(item.base_amount - item.base_net_amount) + else: + item.discount_amount = 0 + + item.unit_rate = abs((abs(item.taxable_value) - item.discount_amount)/ item.qty) + item.gross_amount = abs(item.taxable_value) + item.discount_amount + item.taxable_value = abs(item.taxable_value) item.batch_expiry_date = frappe.db.get_value('Batch', d.batch_no, 'expiry_date') if d.batch_no else None item.batch_expiry_date = format_date(item.batch_expiry_date, 'dd/mm/yyyy') if item.batch_expiry_date else None @@ -202,11 +231,11 @@ def update_item_taxes(invoice, item): is_applicable = t.tax_amount and t.account_head in gst_accounts_list if is_applicable: # this contains item wise tax rate & tax amount (incl. discount) - item_tax_detail = json.loads(t.item_wise_tax_detail).get(item.item_code) + item_tax_detail = json.loads(t.item_wise_tax_detail).get(item.item_code or item.item_name) item_tax_rate = item_tax_detail[0] # item tax amount excluding discount amount - item_tax_amount = (item_tax_rate / 100) * item.base_net_amount + item_tax_amount = (item_tax_rate / 100) * item.taxable_value if t.account_head in gst_accounts.cess_account: item_tax_amount_after_discount = item_tax_detail[1] @@ -227,10 +256,14 @@ def get_invoice_value_details(invoice): invoice_value_details = frappe._dict(dict()) if invoice.apply_discount_on == 'Net Total' and invoice.discount_amount: - invoice_value_details.base_total = abs(invoice.base_total) - invoice_value_details.invoice_discount_amt = abs(invoice.base_discount_amount) + # Discount already applied on net total which means on items + invoice_value_details.base_total = abs(sum([i.taxable_value for i in invoice.get('items')])) + invoice_value_details.invoice_discount_amt = 0 + elif invoice.apply_discount_on == 'Grand Total' and invoice.discount_amount: + invoice_value_details.invoice_discount_amt = invoice.base_discount_amount + invoice_value_details.base_total = abs(sum([i.taxable_value for i in invoice.get('items')])) else: - invoice_value_details.base_total = abs(invoice.base_net_total) + invoice_value_details.base_total = abs(sum([i.taxable_value for i in invoice.get('items')])) # since tax already considers discount amount invoice_value_details.invoice_discount_amt = 0 @@ -251,7 +284,11 @@ def update_invoice_taxes(invoice, invoice_value_details): invoice_value_details.total_igst_amt = 0 invoice_value_details.total_cess_amt = 0 invoice_value_details.total_other_charges = 0 + considered_rows = [] + for t in invoice.taxes: + tax_amount = t.base_tax_amount if (invoice.apply_discount_on == 'Grand Total' and invoice.discount_amount) \ + else t.base_tax_amount_after_discount_amount if t.account_head in gst_accounts_list: if t.account_head in gst_accounts.cess_account: # using after discount amt since item also uses after discount amt for cess calc @@ -259,12 +296,26 @@ def update_invoice_taxes(invoice, invoice_value_details): for tax_type in ['igst', 'cgst', 'sgst']: if t.account_head in gst_accounts['{}_account'.format(tax_type)]: - invoice_value_details['total_{}_amt'.format(tax_type)] += abs(t.base_tax_amount_after_discount_amount) + + invoice_value_details['total_{}_amt'.format(tax_type)] += abs(tax_amount) + update_other_charges(t, invoice_value_details, gst_accounts_list, invoice, considered_rows) else: - invoice_value_details.total_other_charges += abs(t.base_tax_amount_after_discount_amount) - + invoice_value_details.total_other_charges += abs(tax_amount) + return invoice_value_details +def update_other_charges(tax_row, invoice_value_details, gst_accounts_list, invoice, considered_rows): + prev_row_id = cint(tax_row.row_id) - 1 + if tax_row.account_head in gst_accounts_list and prev_row_id not in considered_rows: + if tax_row.charge_type == 'On Previous Row Amount': + amount = invoice.get('taxes')[prev_row_id].tax_amount_after_discount_amount + invoice_value_details.total_other_charges -= abs(amount) + considered_rows.append(prev_row_id) + if tax_row.charge_type == 'On Previous Row Total': + amount = invoice.get('taxes')[prev_row_id].base_total - invoice.base_net_total + invoice_value_details.total_other_charges -= abs(amount) + considered_rows.append(prev_row_id) + def get_payment_details(invoice): payee_name = invoice.company mode_of_payment = ', '.join([d.mode_of_payment for d in invoice.payments]) @@ -277,6 +328,10 @@ def get_payment_details(invoice): )) def get_return_doc_reference(invoice): + if not invoice.return_against: + frappe.throw(_('For generating IRN, reference to the original invoice is mandatory for a credit note. Please set {} field to generate e-invoice.') + .format(frappe.bold('Return Against')), title=_('Missing Field')) + invoice_date = frappe.db.get_value('Sales Invoice', invoice.return_against, 'posting_date') return frappe._dict(dict( invoice_name=invoice.return_against, invoice_date=format_date(invoice_date, 'dd/mm/yyyy') @@ -284,7 +339,11 @@ def get_return_doc_reference(invoice): def get_eway_bill_details(invoice): if invoice.is_return: - frappe.throw(_('E-Way Bill cannot be generated for Credit Notes & Debit Notes'), title=_('E Invoice Validation Failed')) + frappe.throw(_('E-Way Bill cannot be generated for Credit Notes & Debit Notes. Please clear fields in the Transporter Section of the invoice.'), + title=_('Invalid Fields')) + + if not invoice.distance: + frappe.throw(_('Distance is mandatory for generating e-way bill for an e-invoice.'), title=_('Missing Field')) mode_of_transport = { '': '', 'Road': '1', 'Air': '2', 'Rail': '3', 'Ship': '4' } vehicle_type = { 'Regular': 'R', 'Over Dimensional Cargo (ODC)': 'O' } @@ -302,9 +361,15 @@ def get_eway_bill_details(invoice): def validate_mandatory_fields(invoice): if not invoice.company_address: - frappe.throw(_('Company Address is mandatory to fetch company GSTIN details.'), title=_('Missing Fields')) + frappe.throw( + _('Company Address is mandatory to fetch company GSTIN details. Please set Company Address and try again.'), + title=_('Missing Fields') + ) if not invoice.customer_address: - frappe.throw(_('Customer Address is mandatory to fetch customer GSTIN details.'), title=_('Missing Fields')) + frappe.throw( + _('Customer Address is mandatory to fetch customer GSTIN details. Please set Company Address and try again.'), + title=_('Missing Fields') + ) if not frappe.db.get_value('Address', invoice.company_address, 'gstin'): frappe.throw( _('GSTIN is mandatory to fetch company GSTIN details. Please enter GSTIN in selected company address.'), @@ -316,6 +381,39 @@ def validate_mandatory_fields(invoice): title=_('Missing Fields') ) +def validate_totals(einvoice): + item_list = einvoice['ItemList'] + value_details = einvoice['ValDtls'] + + total_item_ass_value = 0 + total_item_cgst_value = 0 + total_item_sgst_value = 0 + total_item_igst_value = 0 + total_item_value = 0 + for item in item_list: + total_item_ass_value += flt(item['AssAmt']) + total_item_cgst_value += flt(item['CgstAmt']) + total_item_sgst_value += flt(item['SgstAmt']) + total_item_igst_value += flt(item['IgstAmt']) + total_item_value += flt(item['TotItemVal']) + + if abs(flt(item['AssAmt']) * flt(item['GstRt']) / 100) - (flt(item['CgstAmt']) + flt(item['SgstAmt']) + flt(item['IgstAmt'])) > 1: + frappe.throw(_('Row #{}: GST rate is invalid. Please remove tax rows with zero tax amount from taxes table.').format(item.idx)) + + if abs(flt(value_details['AssVal']) - total_item_ass_value) > 1: + frappe.throw(_('Total Taxable Value of the items is not equal to the Invoice Net Total. Please check item taxes / discounts for any correction.')) + + if abs(flt(value_details['TotInvVal']) + flt(value_details['Discount']) - flt(value_details['OthChrg']) - total_item_value) > 1: + frappe.throw(_('Total Value of the items is not equal to the Invoice Grand Total. Please check item taxes / discounts for any correction.')) + + calculated_invoice_value = \ + flt(value_details['AssVal']) + flt(value_details['CgstVal']) \ + + flt(value_details['SgstVal']) + flt(value_details['IgstVal']) \ + + flt(value_details['OthChrg']) - flt(value_details['Discount']) + + if abs(flt(value_details['TotInvVal']) - calculated_invoice_value) > 1: + frappe.throw(_('Total Item Value + Taxes - Discount is not equal to the Invoice Grand Total. Please check taxes / discounts for any correction.')) + def make_einvoice(invoice): validate_mandatory_fields(invoice) @@ -325,35 +423,38 @@ def make_einvoice(invoice): item_list = get_item_list(invoice) doc_details = get_doc_details(invoice) invoice_value_details = get_invoice_value_details(invoice) - seller_details = get_party_details(invoice.company_address, company_address=1) + seller_details = get_party_details(invoice.company_address) if invoice.gst_category == 'Overseas': buyer_details = get_overseas_address_details(invoice.customer_address) else: - buyer_details = get_party_details(invoice.customer_address, billing_address=1) + buyer_details = get_party_details(invoice.customer_address) place_of_supply = get_place_of_supply(invoice, invoice.doctype) if place_of_supply: place_of_supply = place_of_supply.split('-')[0] else: place_of_supply = invoice.billing_address_gstin[:2] buyer_details.update(dict(place_of_supply=place_of_supply)) + + seller_details.update(dict(legal_name=invoice.company)) + buyer_details.update(dict(legal_name=invoice.customer_name or invoice.customer)) shipping_details = payment_details = prev_doc_details = eway_bill_details = frappe._dict({}) if invoice.shipping_address_name and invoice.customer_address != invoice.shipping_address_name: if invoice.gst_category == 'Overseas': shipping_details = get_overseas_address_details(invoice.shipping_address_name) else: - shipping_details = get_party_details(invoice.shipping_address_name, shipping_address=1) + shipping_details = get_party_details(invoice.shipping_address_name, shipping_address=True) if invoice.is_pos and invoice.base_paid_amount: payment_details = get_payment_details(invoice) if invoice.is_return and invoice.return_against: prev_doc_details = get_return_doc_reference(invoice) - - if invoice.transporter: + + if invoice.transporter and flt(invoice.distance) and not invoice.is_return: eway_bill_details = get_eway_bill_details(invoice) - + # not yet implemented dispatch_details = period_details = export_details = frappe._dict({}) @@ -364,80 +465,86 @@ def make_einvoice(invoice): period_details=period_details, prev_doc_details=prev_doc_details, export_details=export_details, eway_bill_details=eway_bill_details ) - einvoice = json.loads(einvoice) - validations = json.loads(read_json('einv_validation')) - errors = validate_einvoice(validations, einvoice) - if errors: - message = "\n".join([ - "E Invoice: ", json.dumps(einvoice, indent=4), - "-" * 50, - "Errors: ", json.dumps(errors, indent=4) - ]) - frappe.log_error(title="E Invoice Validation Failed", message=message) - throw_error_list(errors, _('E Invoice Validation Failed')) + try: + einvoice = safe_json_load(einvoice) + einvoice = santize_einvoice_fields(einvoice) + except Exception: + show_link_to_error_log(invoice, einvoice) + + validate_totals(einvoice) return einvoice -def throw_error_list(errors, title): - if len(errors) > 1: - li = ['
  • '+ d +'
  • ' for d in errors] - frappe.throw("".format(''.join(li)), title=title) - else: - frappe.throw(errors[0], title=title) +def show_link_to_error_log(invoice, einvoice): + err_log = log_error(einvoice) + link_to_error_log = get_link_to_form('Error Log', err_log.name, 'Error Log') + frappe.throw( + _('An error occurred while creating e-invoice for {}. Please check {} for more information.').format( + invoice.name, link_to_error_log), + title=_('E Invoice Creation Failed') + ) -def validate_einvoice(validations, einvoice, errors=None): - if errors is None: - errors = [] - for fieldname, field_validation in validations.items(): - value = einvoice.get(fieldname, None) - if not value or value == "None": - # remove keys with empty values - einvoice.pop(fieldname, None) - continue +def log_error(data=None): + if isinstance(data, six.string_types): + data = json.loads(data) - value_type = field_validation.get("type").lower() - if value_type in ['object', 'array']: - child_validations = field_validation.get('properties') + seperator = "--" * 50 + err_tb = traceback.format_exc() + err_msg = str(sys.exc_info()[1]) + data = json.dumps(data, indent=4) - if isinstance(value, list): - for d in value: - validate_einvoice(child_validations, d, errors) - if not d: - # remove empty dicts - einvoice.pop(fieldname, None) + message = "\n".join([ + "Error", err_msg, seperator, + "Data:", data, seperator, + "Exception:", err_tb + ]) + frappe.log_error(title=_('E Invoice Request Failed'), message=message) + +def santize_einvoice_fields(einvoice): + int_fields = ["Pin","Distance","CrDay"] + float_fields = ["Qty","FreeQty","UnitPrice","TotAmt","Discount","PreTaxVal","AssAmt","GstRt","IgstAmt","CgstAmt","SgstAmt","CesRt","CesAmt","CesNonAdvlAmt","StateCesRt","StateCesAmt","StateCesNonAdvlAmt","OthChrg","TotItemVal","AssVal","CgstVal","SgstVal","IgstVal","CesVal","StCesVal","Discount","OthChrg","RndOffAmt","TotInvVal","TotInvValFc","PaidAmt","PaymtDue","ExpDuty",] + copy = einvoice.copy() + for key, value in copy.items(): + if isinstance(value, list): + for idx, d in enumerate(value): + santized_dict = santize_einvoice_fields(d) + if santized_dict: + einvoice[key][idx] = santized_dict + else: + einvoice[key].pop(idx) + + if not einvoice[key]: + einvoice.pop(key, None) + + elif isinstance(value, dict): + santized_dict = santize_einvoice_fields(value) + if santized_dict: + einvoice[key] = santized_dict else: - validate_einvoice(child_validations, value, errors) - if not value: - # remove empty dicts - einvoice.pop(fieldname, None) - continue + einvoice.pop(key, None) - # convert to int or str - if value_type == 'string': - einvoice[fieldname] = str(value) - elif value_type == 'number': - is_integer = '.' not in str(field_validation.get('maximum')) - precision = 3 if '.999' in str(field_validation.get('maximum')) else 2 - einvoice[fieldname] = flt(value, precision) if not is_integer else cint(value) - value = einvoice[fieldname] + elif not value or value == "None": + einvoice.pop(key, None) - max_length = field_validation.get('maxLength') - minimum = flt(field_validation.get('minimum')) - maximum = flt(field_validation.get('maximum')) - pattern_str = field_validation.get('pattern') - pattern = re.compile(pattern_str or '') + elif key in float_fields: + einvoice[key] = flt(value, 2) - label = field_validation.get('description') or fieldname + elif key in int_fields: + einvoice[key] = cint(value) - if value_type == 'string' and len(value) > max_length: - errors.append(_('{} should not exceed {} characters').format(label, max_length)) - if value_type == 'number' and (value > maximum or value < minimum): - errors.append(_('{} {} should be between {} and {}').format(label, value, minimum, maximum)) - if pattern_str and not pattern.match(value): - errors.append(field_validation.get('validationMsg')) - - return errors + return einvoice + +def safe_json_load(json_string): + JSONDecodeError = ValueError if six.PY2 else json.JSONDecodeError + try: + return json.loads(json_string) + except JSONDecodeError as e: + # print a snippet of 40 characters around the location where error occured + pos = e.pos + start, end = max(0, pos-20), min(len(json_string)-1, pos+20) + snippet = json_string[start:end] + frappe.throw(_("Error in input data. Please check for any special characters near following input:
    {}").format(snippet)) class RequestFailed(Exception): pass @@ -463,13 +570,17 @@ class GSPConnector(): def get_credentials(self): if self.invoice: gstin = self.get_seller_gstin() - credentials = next(d for d in self.e_invoice_settings.credentials if d.gstin == gstin) + credentials_for_gstin = [d for d in self.e_invoice_settings.credentials if d.gstin == gstin] + if credentials_for_gstin: + self.credentials = credentials_for_gstin[0] + else: + frappe.throw(_('Cannot find e-invoicing credentials for selected Company GSTIN. Please check E-Invoice Settings')) else: credentials = self.e_invoice_settings.credentials[0] if self.e_invoice_settings.credentials else None return credentials def get_seller_gstin(self): - gstin = self.invoice.company_gstin or frappe.db.get_value('Address', self.invoice.company_address, 'gstin') + gstin = frappe.db.get_value('Address', self.invoice.company_address, 'gstin') if not gstin: frappe.throw(_('Cannot retrieve Company GSTIN. Please select company address with valid GSTIN.')) return gstin @@ -517,7 +628,7 @@ class GSPConnector(): self.e_invoice_settings.reload() except Exception: - self.log_error(res) + log_error(res) self.raise_error(True) def get_headers(self): @@ -539,14 +650,14 @@ class GSPConnector(): if res.get('success'): return res.get('result') else: - self.log_error(res) + log_error(res) raise RequestFailed except RequestFailed: self.raise_error() except Exception: - self.log_error() + log_error() self.raise_error(True) @staticmethod @@ -592,7 +703,7 @@ class GSPConnector(): self.raise_error(errors=errors) except Exception: - self.log_error(data) + log_error(data) self.raise_error(True) def get_irn_details(self, irn): @@ -611,7 +722,7 @@ class GSPConnector(): self.raise_error(errors=errors) except Exception: - self.log_error() + log_error() self.raise_error(True) def cancel_irn(self, irn, reason, remark): @@ -624,7 +735,7 @@ class GSPConnector(): try: res = self.make_request('post', self.cancel_irn_url, headers, data) - if res.get('success'): + if res.get('success') or '9999' in res.get('message'): self.invoice.irn_cancelled = 1 self.invoice.flags.updater_reference = { 'doctype': self.invoice.doctype, @@ -641,7 +752,7 @@ class GSPConnector(): self.raise_error(errors=errors) except Exception: - self.log_error(data) + log_error(data) self.raise_error(True) def generate_eway_bill(self, **kwargs): @@ -682,7 +793,7 @@ class GSPConnector(): self.raise_error(errors=errors) except Exception: - self.log_error(data) + log_error(data) self.raise_error(True) def cancel_eway_bill(self, eway_bill, reason, remark): @@ -715,7 +826,7 @@ class GSPConnector(): self.raise_error(errors=errors) except Exception: - self.log_error(data) + log_error(data) self.raise_error(True) def sanitize_error_message(self, message): @@ -740,22 +851,6 @@ class GSPConnector(): errors[idx] = errors[idx][:-6] return errors - - def log_error(self, data={}): - if not isinstance(data, dict): - data = json.loads(data) - - seperator = "--" * 50 - err_tb = traceback.format_exc() - err_msg = str(sys.exc_info()[1]) - data = json.dumps(data, indent=4) - - message = "\n".join([ - "Error", err_msg, seperator, - "Data:", data, seperator, - "Exception:", err_tb - ]) - frappe.log_error(title=_('E Invoice Request Failed'), message=message) def raise_error(self, raise_exception=False, errors=[]): title = _('E Invoice Request Failed') @@ -776,6 +871,8 @@ class GSPConnector(): self.invoice.irn = res.get('Irn') self.invoice.ewaybill = res.get('EwbNo') + self.invoice.ack_no = res.get('AckNo') + self.invoice.ack_date = res.get('AckDt') self.invoice.signed_einvoice = dec_signed_invoice self.invoice.signed_qr_code = res.get('SignedQRCode') @@ -815,6 +912,11 @@ class GSPConnector(): self.invoice.flags.ignore_validate = True self.invoice.save() +def sanitize_for_json(string): + """Escape JSON specific characters from a string.""" + # json.dumps adds double-quotes to the string. Indexing to remove them. + return json.dumps(string)[1:-1] + @frappe.whitelist() def get_einvoice(doctype, docname): invoice = frappe.get_doc(doctype, docname) @@ -837,5 +939,9 @@ def generate_eway_bill(doctype, docname, **kwargs): @frappe.whitelist() def cancel_eway_bill(doctype, docname, eway_bill, reason, remark): - gsp_connector = GSPConnector(doctype, docname) - gsp_connector.cancel_eway_bill(eway_bill, reason, remark) + # TODO: uncomment when eway_bill api from Adequare is enabled + # gsp_connector = GSPConnector(doctype, docname) + # gsp_connector.cancel_eway_bill(eway_bill, reason, remark) + + # update cancelled status only, to be able to cancel irn next + frappe.db.set_value(doctype, docname, 'eway_bill_cancelled', 1) diff --git a/erpnext/regional/india/setup.py b/erpnext/regional/india/setup.py index d920a73ff4e..d417360d938 100644 --- a/erpnext/regional/india/setup.py +++ b/erpnext/regional/india/setup.py @@ -116,6 +116,9 @@ def make_custom_fields(update=True): is_non_gst = dict(fieldname='is_non_gst', label='Is Non GST', fieldtype='Check', fetch_from='item_code.is_non_gst', insert_after='is_nil_exempt', print_hide=1) + taxable_value = dict(fieldname='taxable_value', label='Taxable Value', + fieldtype='Currency', insert_after='base_net_amount', hidden=1, options="Company:company:default_currency", + print_hide=1) purchase_invoice_gst_category = [ dict(fieldname='gst_section', label='GST Details', fieldtype='Section Break', @@ -451,7 +454,7 @@ def make_custom_fields(update=True): 'Supplier Quotation Item': [hsn_sac_field, nil_rated_exempt, is_non_gst], 'Sales Order Item': [hsn_sac_field, nil_rated_exempt, is_non_gst], 'Delivery Note Item': [hsn_sac_field, nil_rated_exempt, is_non_gst], - 'Sales Invoice Item': [hsn_sac_field, nil_rated_exempt, is_non_gst], + 'Sales Invoice Item': [hsn_sac_field, nil_rated_exempt, is_non_gst, taxable_value], 'Purchase Order Item': [hsn_sac_field, nil_rated_exempt, is_non_gst], 'Purchase Receipt Item': [hsn_sac_field, nil_rated_exempt, is_non_gst], 'Purchase Invoice Item': [hsn_sac_field, nil_rated_exempt, is_non_gst], diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py index 5d5c2e9610c..f973dc657fe 100644 --- a/erpnext/regional/india/utils.py +++ b/erpnext/regional/india/utils.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals import frappe, re, json from frappe import _ import erpnext -from frappe.utils import cstr, flt, date_diff, nowdate, round_based_on_smallest_currency_fraction, money_in_words, getdate +from frappe.utils import cstr, flt, cint, date_diff, nowdate, round_based_on_smallest_currency_fraction, money_in_words, getdate from erpnext.regional.india import states, state_numbers from erpnext.controllers.taxes_and_totals import get_itemised_tax, get_itemised_taxable_amount, calculate_outstanding_amount from erpnext.controllers.accounts_controller import get_taxes_and_charges @@ -827,3 +827,48 @@ def get_gst_tax_amount(doc): gst_tax -= tax.tax_amount_after_discount_amount return gst_tax, base_gst_tax + +def update_taxable_values(doc, method): + country = frappe.get_cached_value('Company', doc.company, 'country') + + if country != 'India': + return + + gst_accounts = get_gst_accounts(doc.company) + + # Only considering sgst account to avoid inflating taxable value + gst_account_list = gst_accounts.get('sgst_account', []) + gst_accounts.get('sgst_account', []) \ + + gst_accounts.get('igst_account', []) + + additional_taxes = 0 + total_charges = 0 + item_count = 0 + considered_rows = [] + + for tax in doc.get('taxes'): + prev_row_id = cint(tax.row_id) - 1 + if tax.account_head in gst_account_list and prev_row_id not in considered_rows: + if tax.charge_type == 'On Previous Row Amount': + additional_taxes += doc.get('taxes')[prev_row_id].tax_amount_after_discount_amount + considered_rows.append(prev_row_id) + if tax.charge_type == 'On Previous Row Total': + additional_taxes += doc.get('taxes')[prev_row_id].base_total - doc.base_net_total + considered_rows.append(prev_row_id) + + for item in doc.get('items'): + if doc.apply_discount_on == 'Grand Total' and doc.discount_amount: + proportionate_value = item.base_amount if doc.base_total else item.qty + total_value = doc.base_total if doc.base_total else doc.total_qty + else: + proportionate_value = item.base_net_amount if doc.base_net_total else item.qty + total_value = doc.base_net_total if doc.base_net_total else doc.total_qty + + applicable_charges = flt(flt(proportionate_value * (flt(additional_taxes) / flt(total_value)), + item.precision('taxable_value'))) + item.taxable_value = applicable_charges + proportionate_value + total_charges += applicable_charges + item_count += 1 + + if total_charges != additional_taxes: + diff = additional_taxes - total_charges + doc.get('items')[item_count - 1].taxable_value += diff