diff --git a/addons/l10n_in_edi/__init__.py b/addons/l10n_in_edi/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/l10n_in_edi/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/l10n_in_edi/__manifest__.py b/addons/l10n_in_edi/__manifest__.py new file mode 100644 index 00000000000..329aa2ac203 --- /dev/null +++ b/addons/l10n_in_edi/__manifest__.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + "name": """Indian - E-invoicing""", + "version": "1.03.00", + "icon": "/l10n_in/static/description/icon.png", + "category": "Accounting/Localizations/EDI", + "depends": [ + "account_edi", + "l10n_in", + "iap", + ], + "description": """ +Indian - E-invoicing +==================== +To submit invoicing through API to the government. +We use "Tera Software Limited" as GSP + +Step 1: First you need to create an API username and password in the E-invoice portal. +Step 2: Switch to company related to that GST number +Step 3: Set that username and password in Odoo (Goto: Invoicing/Accounting -> Configration -> Settings -> Customer Invoices or find "E-invoice" in search bar) +Step 4: Repeat steps 1,2,3 for all GSTIN you have in odoo. If you have a multi-company with the same GST number then perform step 1 for the first company only. + +For the creation of API username and password please ref this document: + """, + "data": [ + "data/account_edi_data.xml", + "views/res_config_settings_views.xml", + "views/edi_pdf_report.xml", + "views/account_move_views.xml", + ], + "demo": [ + "demo/demo_company.xml", + ], + "installable": True, + # only applicable for taxpayers turnover higher than Rs.50 crore so auto_install is False + "auto_install": False, + "application": False, + "license": "LGPL-3", +} diff --git a/addons/l10n_in_edi/data/account_edi_data.xml b/addons/l10n_in_edi/data/account_edi_data.xml new file mode 100644 index 00000000000..c83d4640b44 --- /dev/null +++ b/addons/l10n_in_edi/data/account_edi_data.xml @@ -0,0 +1,7 @@ + + + + E-Invoice (IN) + in_einvoice_1_03 + + diff --git a/addons/l10n_in_edi/demo/demo_company.xml b/addons/l10n_in_edi/demo/demo_company.xml new file mode 100644 index 00000000000..e1ce0921c3c --- /dev/null +++ b/addons/l10n_in_edi/demo/demo_company.xml @@ -0,0 +1,8 @@ + + + + + MGSTTEST + mgst@123 + + diff --git a/addons/l10n_in_edi/models/__init__.py b/addons/l10n_in_edi/models/__init__.py new file mode 100644 index 00000000000..26d01b730c3 --- /dev/null +++ b/addons/l10n_in_edi/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_edi_format +from . import account_move +from . import res_company +from . import res_config_settings diff --git a/addons/l10n_in_edi/models/account_edi_format.py b/addons/l10n_in_edi/models/account_edi_format.py new file mode 100644 index 00000000000..ea2fb8e8445 --- /dev/null +++ b/addons/l10n_in_edi/models/account_edi_format.py @@ -0,0 +1,571 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import re +import json +import pytz +import markupsafe + +from odoo import models, fields, api, _ +from odoo.tools import html_escape +from odoo.exceptions import AccessError +from odoo.addons.iap import jsonrpc +import logging + +_logger = logging.getLogger(__name__) + +DEFAULT_IAP_ENDPOINT = "https://l10n-in-edi.api.odoo.com" +DEFAULT_IAP_TEST_ENDPOINT = "https://l10n-in-edi-demo.api.odoo.com" + + +class AccountEdiFormat(models.Model): + _inherit = "account.edi.format" + + def _is_enabled_by_default_on_journal(self, journal): + self.ensure_one() + if self.code == "in_einvoice_1_03": + return journal.company_id.country_id.code == 'IN' + return super()._is_enabled_by_default_on_journal(journal) + + def _is_required_for_invoice(self, invoice): + self.ensure_one() + if self.code == "in_einvoice_1_03": + return invoice.is_sale_document() and invoice.country_code == 'IN' and invoice.l10n_in_gst_treatment in ( + "regular", + "composition", + "overseas", + "special_economic_zone", + "deemed_export", + ) + return super()._is_required_for_invoice(invoice) + + def _needs_web_services(self): + self.ensure_one() + return self.code == "in_einvoice_1_03" or super()._needs_web_services() + + def _get_invoice_edi_content(self, move): + if self.code != "in_einvoice_1_03": + return super()._get_invoice_edi_content(move) + json_dump = json.dumps(self._l10n_in_edi_generate_invoice_json(move)) + return json_dump.encode() + + def _l10n_in_edi_extract_digits(self, string): + if not string: + return string + matches = re.findall(r"\d+", string) + result = "".join(matches) + return result + + def _check_move_configuration(self, move): + if self.code != "in_einvoice_1_03": + return super()._check_move_configuration(move) + error_message = [] + error_message += self._l10n_in_validate_partner(move.partner_id) + error_message += self._l10n_in_validate_partner(move.company_id.partner_id, is_company=True) + if not re.match("^.{1,16}$", move.name): + error_message.append(_("Invoice number should not be more than 16 characters")) + for line in move.invoice_line_ids.filtered(lambda line: not (line.display_type or line.is_rounding_line)): + if line.product_id: + hsn_code = self._l10n_in_edi_extract_digits(line.product_id.l10n_in_hsn_code) + if not hsn_code: + error_message.append(_("HSN code is not set in product %s", line.product_id.name)) + elif not re.match("^[0-9]+$", hsn_code): + error_message.append(_( + "Invalid HSN Code (%s) in product %s", hsn_code, line.product_id.name + )) + else: + error_message.append(_("product is required to get HSN code")) + return error_message + + def _l10n_in_edi_get_iap_buy_credits_message(self, company): + base_url = "https://iap-sandbox.odoo.com/iap/1/credit" if not company.sudo().l10n_in_edi_production_env else "" + url = self.env["iap.account"].get_credits_url(service_name="l10n_in_edi", base_url=base_url) + return markupsafe.Markup("""

%s

%s

""" % ( + _("You have insufficient credits to send this document!"), + _("Please proceed to buy more credits here.", url), + )) + + def _post_invoice_edi(self, invoices): + if self.code != "in_einvoice_1_03": + return super()._post_invoice_edi(invoices) + response = {} + res = {} + generate_json = self._l10n_in_edi_generate_invoice_json(invoices) + response = self._l10n_in_edi_generate(invoices.company_id, generate_json) + if response.get("error"): + error = response["error"] + error_codes = [e.get("code") for e in error] + if "1005" in error_codes: + # Invalid token eror then create new token and send generate request again. + # This happen when authenticate called from another odoo instance with same credentials (like. Demo/Test) + authenticate_response = self._l10n_in_edi_authenticate(invoices.company_id) + if not authenticate_response.get("error"): + error = [] + response = self._l10n_in_edi_generate(invoices.company_id, generate_json) + if response.get("error"): + error = response["error"] + error_codes = [e.get("code") for e in error] + if "2150" in error_codes: + # Get IRN by details in case of IRN is already generated + # this happens when timeout from the Government portal but IRN is generated + response = self._l10n_in_edi_get_irn_by_details(invoices.company_id, { + "doc_type": invoices.move_type == "out_refund" and "CRN" or "INV", + "doc_num": invoices.name, + "doc_date": invoices.invoice_date and invoices.invoice_date.strftime("%d/%m/%Y") or False, + }) + if not response.get("error"): + error = [] + odoobot = self.env.ref("base.partner_root") + invoices.message_post(author_id=odoobot.id, body=_( + "Somehow this invoice had been submited to government before." \ + "
Normally, this should not happen too often" \ + "
Just verify value of invoice by uploade json to government website " \ + "here." + )) + if "no-credit" in error_codes: + res[invoices] = { + "success": False, + "error": self._l10n_in_edi_get_iap_buy_credits_message(invoices.company_id), + "blocking_level": "error", + } + elif error: + error_message = "
".join(["[%s] %s" % (e.get("code"), html_escape(e.get("message"))) for e in error]) + res[invoices] = { + "success": False, + "error": error_message, + "blocking_level": ("404" in error_codes) and "warning" or "error", + } + if not response.get("error"): + json_dump = json.dumps(response.get("data")) + json_name = "%s_einvoice.json" % (invoices.name.replace("/", "_")) + attachment = self.env["ir.attachment"].create({ + "name": json_name, + "raw": json_dump.encode(), + "res_model": "account.move", + "res_id": invoices.id, + "mimetype": "application/json", + }) + res[invoices] = {"success": True, "attachment": attachment} + return res + + def _cancel_invoice_edi(self, invoices): + if self.code != "in_einvoice_1_03": + return super()._cancel_invoice_edi(invoices) + res = {} + for invoice in invoices: + l10n_in_edi_response_json = invoice._get_l10n_in_edi_response_json() + cancel_json = { + "Irn": l10n_in_edi_response_json.get("Irn"), + "CnlRsn": invoice.l10n_in_edi_cancel_reason, + "CnlRem": invoice.l10n_in_edi_cancel_remarks, + } + response = self._l10n_in_edi_cancel(invoice.company_id, cancel_json) + if response.get("error"): + error = response["error"] + error_codes = [e.get("code") for e in error] + if "1005" in error_codes: + # Invalid token eror then create new token and send generate request again. + # This happen when authenticate called from another odoo instance with same credentials (like. Demo/Test) + authenticate_response = self._l10n_in_edi_authenticate(invoice.company_id) + if not authenticate_response.get("error"): + error = [] + response = self._l10n_in_edi_cancel(invoice.company_id, cancel_json) + if response.get("error"): + error = response["error"] + error_codes = [e.get("code") for e in error] + if "9999" in error_codes: + response = {} + odoobot = self.env.ref("base.partner_root") + invoices.message_post(author_id=odoobot.id, body=_( + "Somehow this invoice had been cancelled to government before." \ + "
Normally, this should not happen too often" \ + "
Just verify by logging into government website " \ + "
here." + )) + if "no-credit" in error_codes: + res[invoice] = { + "success": False, + "error": self._l10n_in_edi_get_iap_buy_credits_message(invoice.company_id), + "blocking_level": "error", + } + else: + error_message = "
".join(["[%s] %s" % (e.get("code"), html_escape(e.get("message"))) for e in error]) + res[invoice] = { + "success": False, + "error": error_message, + "blocking_level": ("404" in error_codes) and "warning" or "error", + } + if not response.get("error"): + json_dump = json.dumps(response.get("data", {})) + json_name = "%s_cancel_einvoice.json" % (invoice.name.replace("/", "_")) + attachment = self.env["ir.attachment"].create({ + "name": json_name, + "raw": json_dump.encode(), + "res_model": "account.move", + "res_id": invoice.id, + "mimetype": "application/json", + }) + res[invoice] = {"success": True, "attachment": attachment} + return res + + def _l10n_in_validate_partner(self, partner, is_company=False): + self.ensure_one() + message = [] + if not re.match("^.{3,100}$", partner.street or ""): + message.append(_("\n- Street required min 3 and max 100 characters")) + if partner.street2 and not re.match("^.{3,100}$", partner.street2): + message.append(_("\n- Street2 should be min 3 and max 100 characters")) + if not re.match("^.{3,100}$", partner.city or ""): + message.append(_("\n- City required min 3 and max 100 characters")) + if not re.match("^.{3,50}$", partner.state_id.name or ""): + message.append(_("\n- State required min 3 and max 50 characters")) + if partner.country_id.code == "IN" and not re.match("^[0-9]{6,}$", partner.zip or ""): + message.append(_("\n- Zip code required 6 digits")) + if partner.phone and not re.match("^[0-9]{10,12}$", + self._l10n_in_edi_extract_digits(partner.phone) + ): + message.append(_("\n- Mobile number should be minimum 10 or maximum 12 digits")) + if partner.email and ( + not re.match(r"^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$", partner.email) + or not re.match("^.{6,100}$", partner.email) + ): + message.append(_("\n- Email address should be valid and not more then 100 characters")) + return message + + def _get_l10n_in_edi_saler_buyer_party(self, move): + return { + "seller_details": move.company_id.partner_id, + "dispatch_details": move._l10n_in_get_warehouse_address() or move.company_id.partner_id, + "buyer_details": move.partner_id, + "ship_to_details": move.partner_shipping_id or move.partner_id, + } + + @api.model + def _get_l10n_in_edi_partner_details(self, partner, set_vat=True, set_phone_and_email=True, + is_overseas=False, pos_state_id=False): + """ + Create the dictionary based partner details + if set_vat is true then, vat(GSTIN) and legal name(LglNm) is added + if set_phone_and_email is true then phone and email is add + if set_pos is true then state code from partner or passed state_id is added as POS(place of supply) + if is_overseas is true then pin is 999999 and GSTIN(vat) is URP and Stcd is . + if pos_state_id is passed then we use set POS + """ + partner_details = { + "Addr1": partner.street or "", + "Loc": partner.city or "", + "Pin": int(self._l10n_in_edi_extract_digits(partner.zip)), + "Stcd": partner.state_id.l10n_in_tin or "", + } + if partner.street2: + partner_details.update({"Addr2": partner.street2}) + if set_phone_and_email: + if partner.email: + partner_details.update({"Em": partner.email}) + if partner.phone: + partner_details.update({"Ph": self._l10n_in_edi_extract_digits(partner.phone)}) + if pos_state_id: + partner_details.update({"POS": pos_state_id.l10n_in_tin or ""}) + if set_vat: + partner_details.update({ + "LglNm": partner.commercial_partner_id.name, + "GSTIN": partner.vat or "", + }) + else: + partner_details.update({"Nm": partner.name}) + if is_overseas: + partner_details.update({ + "GSTIN": "URP", + "Pin": 999999, + "Stcd": "96", + "POS": "96", + }) + return partner_details + + @api.model + def _l10n_in_round_value(self, amount, precision_digits=2): + """ + This method is call for rounding. + If anything is wrong with rounding then we quick fix in method + """ + return round(amount, precision_digits) + + def _get_l10n_in_edi_line_details(self, index, line, line_tax_details, sign): + """ + Create the dictionary with line details + return { + account.move.line('1'): {....}, + account.move.line('2'): {....}, + .... + } + """ + tax_details_by_code = self._get_l10n_in_tax_details_by_line_code(line_tax_details.get("tax_details", {})) + return { + "SlNo": str(index), + "PrdDesc": line.name.replace("\n", ""), + "IsServc": line.product_id.type == "service" and "Y" or "N", + "HsnCd": self._l10n_in_edi_extract_digits(line.product_id.l10n_in_hsn_code), + "Qty": self._l10n_in_round_value(line.quantity or 0.0, 3), + "Unit": line.product_uom_id.l10n_in_code and line.product_uom_id.l10n_in_code.split("-")[0] or "OTH", + # Unit price in company currency and tax excluded so its different then price_unit + "UnitPrice": self._l10n_in_round_value( + ((line.balance / (1 - (line.discount / 100))) / line.quantity) * sign, 3), + # total amount is before discount + "TotAmt": self._l10n_in_round_value((line.balance / (1 - (line.discount / 100))) * sign), + "Discount": self._l10n_in_round_value( + ((line.balance / (1 - (line.discount / 100))) - line.balance) * sign), + "AssAmt": self._l10n_in_round_value(line.balance * sign), + "GstRt": self._l10n_in_round_value(tax_details_by_code.get("igst_rate", 0.00) or ( + tax_details_by_code.get("cgst_rate", 0.00) + tax_details_by_code.get("sgst_rate", 0.00)), 3), + "IgstAmt": self._l10n_in_round_value(tax_details_by_code.get("igst_amount", 0.00) * sign), + "CgstAmt": self._l10n_in_round_value(tax_details_by_code.get("cgst_amount", 0.00) * sign), + "SgstAmt": self._l10n_in_round_value(tax_details_by_code.get("sgst_amount", 0.00) * sign), + "CesRt": self._l10n_in_round_value(tax_details_by_code.get("cess_rate", 0.00), 3), + "CesAmt": self._l10n_in_round_value(tax_details_by_code.get("cess_amount", 0.00) * sign), + "CesNonAdvlAmt": self._l10n_in_round_value( + tax_details_by_code.get("cess_non_advol_amount", 0.00) * sign), + "StateCesRt": self._l10n_in_round_value(tax_details_by_code.get("state_cess_rate_amount", 0.00), 3), + "StateCesAmt": self._l10n_in_round_value(tax_details_by_code.get("state_cess_amount", 0.00) * sign), + "StateCesNonAdvlAmt": self._l10n_in_round_value( + tax_details_by_code.get("state_cess_non_advol_amount", 0.00) * sign), + "OthChrg": self._l10n_in_round_value(tax_details_by_code.get("other_amount", 0.00) * sign), + "TotItemVal": self._l10n_in_round_value( + (line.balance + line_tax_details.get("tax_amount", 0.00)) * sign), + } + + def _l10n_in_edi_generate_invoice_json(self, invoice): + tax_details = self._l10n_in_prepare_edi_tax_details(invoice) + saler_buyer = self._get_l10n_in_edi_saler_buyer_party(invoice) + tax_details_by_code = self._get_l10n_in_tax_details_by_line_code(tax_details.get("tax_details", {})) + sign = invoice.is_inbound() and -1 or 1 + is_intra_state = invoice.l10n_in_state_id == invoice.company_id.state_id + is_overseas = invoice.l10n_in_gst_treatment == "overseas" + lines = invoice.invoice_line_ids.filtered(lambda line: not (line.display_type or line.is_rounding_line)) + invoice_line_tax_details = tax_details.get("invoice_line_tax_details") + json_payload = { + "Version": "1.1", + "TranDtls": { + "TaxSch": "GST", + "SupTyp": self._l10n_in_get_supply_type(invoice, tax_details_by_code), + "RegRev": tax_details_by_code.get("is_reverse_charge") and "Y" or "N", + "IgstOnIntra": is_intra_state and tax_details_by_code.get("igst") and "Y" or "N"}, + "DocDtls": { + "Typ": invoice.move_type == "out_refund" and "CRN" or "INV", + "No": invoice.name, + "Dt": invoice.invoice_date.strftime("%d/%m/%Y")}, + "SellerDtls": self._get_l10n_in_edi_partner_details(saler_buyer.get("seller_details")), + "BuyerDtls": self._get_l10n_in_edi_partner_details( + saler_buyer.get("buyer_details"), pos_state_id=invoice.l10n_in_state_id, is_overseas=is_overseas), + "ItemList": [ + self._get_l10n_in_edi_line_details(index, line, invoice_line_tax_details.get(line, {}), sign) + for index, line in enumerate(lines, start=1) + ], + "ValDtls": { + "AssVal": self._l10n_in_round_value(tax_details.get("base_amount") * sign), + "CgstVal": self._l10n_in_round_value(tax_details_by_code.get("cgst_amount", 0.00) * sign), + "SgstVal": self._l10n_in_round_value(tax_details_by_code.get("sgst_amount", 0.00) * sign), + "IgstVal": self._l10n_in_round_value(tax_details_by_code.get("igst_amount", 0.00) * sign), + "CesVal": self._l10n_in_round_value(( + tax_details_by_code.get("cess_amount", 0.00) + + tax_details_by_code.get("cess_non_advol_amount", 0.00)) * sign, + ), + "StCesVal": self._l10n_in_round_value(( + tax_details_by_code.get("state_cess_amount", 0.00) + + tax_details_by_code.get("state_cess_non_advol_amount", 0.00)) * sign, + ), + "RndOffAmt": self._l10n_in_round_value( + sum(line.balance for line in invoice.invoice_line_ids if line.is_rounding_line)), + "TotInvVal": self._l10n_in_round_value( + (tax_details.get("base_amount") + tax_details.get("tax_amount")) * sign), + }, + } + if invoice.company_currency_id != invoice.currency_id: + json_payload["ValDtls"].update({ + "TotInvValFc": self._l10n_in_round_value( + (tax_details.get("base_amount_currency") + tax_details.get("tax_amount_currency")) * sign) + }) + if saler_buyer.get("seller_details") != saler_buyer.get("dispatch_details"): + json_payload.update({ + "DispDtls": self._get_l10n_in_edi_partner_details(saler_buyer.get("dispatch_details"), + set_vat=False, set_phone_and_email=False) + }) + if saler_buyer.get("buyer_details") != saler_buyer.get("ship_to_details"): + json_payload.update({ + "ShipDtls": self._get_l10n_in_edi_partner_details(saler_buyer.get("ship_to_details")) + }) + if is_overseas: + json_payload.update({ + "ExpDtls": { + "ShipBNo": invoice.l10n_in_shipping_bill_number or "", + "ShipBDt": invoice.l10n_in_shipping_bill_date + and invoice.l10n_in_shipping_bill_date.strftime("%d/%m/%Y") or "", + "Port": invoice.l10n_in_shipping_port_code_id.code or "", + "RefClm": tax_details_by_code.get("igst") and "Y" or "N", + "ForCur": invoice.currency_id.name, + "CntCode": saler_buyer.get("buyer_details").country_id.code or "", + } + }) + return json_payload + + @api.model + def _l10n_in_prepare_edi_tax_details(self, move, in_foreign=False): + def l10n_in_grouping_key_generator(tax_values): + base_line = tax_values["base_line_id"] + tax_line = tax_values["tax_line_id"] + line_code = "other" + if tax_line.tax_tag_ids in self.env.ref("l10n_in.tax_report_line_cess").sudo().tag_ids: + if tax_line.tax_line_id.amount_type != "percent": + line_code = "cess_non_advol" + else: + line_code = "cess" + elif tax_line.tax_tag_ids in self.env.ref("l10n_in.tax_report_line_state_cess").sudo().tag_ids: + if tax_line.tax_line_id.amount_type != "percent": + line_code = "state_cess_non_advol" + else: + line_code = "state_cess" + else: + for gst in ["cgst", "sgst", "igst"]: + if tax_line.tax_tag_ids in self.env.ref("l10n_in.tax_report_line_%s"%(gst)).sudo().tag_ids: + line_code = gst + return { + "tax": tax_values["tax_id"], + "base_product_id": base_line.product_id, + "tax_product_id": tax_line.product_id, + "base_product_uom_id": base_line.product_uom_id, + "tax_product_uom_id": tax_line.product_uom_id, + "line_code": line_code, + } + + def l10n_in_filter_to_apply(tax_values): + if tax_values["base_line_id"].is_rounding_line: + return False + return True + + return move._prepare_edi_tax_details( + filter_to_apply=l10n_in_filter_to_apply, + grouping_key_generator=l10n_in_grouping_key_generator, + ) + + @api.model + def _get_l10n_in_tax_details_by_line_code(self, tax_details): + l10n_in_tax_details = {} + for tax_detail in tax_details.values(): + if tax_detail["tax"].l10n_in_reverse_charge: + l10n_in_tax_details.setdefault("is_reverse_charge", True) + l10n_in_tax_details.setdefault("%s_rate" % (tax_detail["line_code"]), tax_detail["tax"].amount) + l10n_in_tax_details.setdefault("%s_amount" % (tax_detail["line_code"]), 0.00) + l10n_in_tax_details.setdefault("%s_amount_currency" % (tax_detail["line_code"]), 0.00) + l10n_in_tax_details["%s_amount" % (tax_detail["line_code"])] += tax_detail["tax_amount"] + l10n_in_tax_details["%s_amount_currency" % (tax_detail["line_code"])] += tax_detail["tax_amount_currency"] + return l10n_in_tax_details + + def _l10n_in_get_supply_type(self, move, tax_details_by_code): + supply_type = "B2B" + if move.l10n_in_gst_treatment in ("overseas", "special_economic_zone") and tax_details_by_code.get("igst_amount"): + supply_type = move.l10n_in_gst_treatment == "overseas" and "EXPWP" or "SEZWP" + elif move.l10n_in_gst_treatment in ("overseas", "special_economic_zone"): + supply_type = move.l10n_in_gst_treatment == "overseas" and "EXPWOP" or "SEZWOP" + elif move.l10n_in_gst_treatment == "deemed_export": + supply_type = "DEXP" + return supply_type + + #================================ API methods =========================== + + @api.model + def _l10n_in_edi_no_config_response(self): + return {'error': [{ + 'code': '000', + 'message': _( + "A username and password still needs to be set or it's wrong for the E-invoice(IN). " + "It needs to be added and verify in the Settings." + )} + ]} + + @api.model + def _l10n_in_edi_get_token(self, company): + sudo_company = company.sudo() + if sudo_company.l10n_in_edi_username and sudo_company._l10n_in_edi_token_is_valid(): + return sudo_company.l10n_in_edi_token + elif sudo_company.l10n_in_edi_username and sudo_company.l10n_in_edi_password: + self._l10n_in_edi_authenticate(company) + return sudo_company.l10n_in_edi_token + return False + + @api.model + def _l10n_in_edi_connect_to_server(self, company, url_path, params): + user_token = self.env["iap.account"].get("l10n_in_edi") + params.update({ + "account_token": user_token.account_token, + "dbuuid": self.env["ir.config_parameter"].sudo().get_param("database.uuid"), + "username": company.sudo().l10n_in_edi_username, + "gstin": company.vat, + }) + if company.sudo().l10n_in_edi_production_env: + default_endpoint = DEFAULT_IAP_ENDPOINT + else: + default_endpoint = DEFAULT_IAP_TEST_ENDPOINT + endpoint = self.env["ir.config_parameter"].sudo().get_param("l10n_in_edi.endpoint", default_endpoint) + url = "%s%s" % (endpoint, url_path) + try: + return jsonrpc(url, params=params, timeout=25) + except AccessError as e: + _logger.warning("Connection error: %s", e.args[0]) + return { + "error": [{ + "code": "404", + "message": _("Unable to connect to the online E-invoice service." + "The web service may be temporary down. Please try again in a moment.") + }] + } + + @api.model + def _l10n_in_edi_authenticate(self, company): + params = {"password": company.sudo().l10n_in_edi_password} + response = self._l10n_in_edi_connect_to_server(company, url_path="/iap/l10n_in_edi/1/authenticate", params=params) + # validity data-time in Indian standard time(UTC+05:30) so remove that gap and store in odoo + if "data" in response: + local_time = fields.Datetime.context_timestamp( + self.with_context(tz="Asia/Kolkata"), + fields.Datetime.to_datetime(response["data"]["TokenExpiry"] + )) + utc_time = local_time.astimezone(pytz.utc) + company.sudo().l10n_in_edi_token_validity = fields.Datetime.to_string(utc_time) + company.sudo().l10n_in_edi_token = response["data"]["AuthToken"] + return response + + @api.model + def _l10n_in_edi_generate(self, company, json_payload): + token = self._l10n_in_edi_get_token(company) + if not token: + return self._l10n_in_edi_no_config_response() + params = { + "auth_token": token, + "json_payload": json_payload, + } + return self._l10n_in_edi_connect_to_server(company, url_path="/iap/l10n_in_edi/1/generate", params=params) + + @api.model + def _l10n_in_edi_get_irn_by_details(self, company, json_payload): + token = self._l10n_in_edi_get_token(company) + if not token: + return self._l10n_in_edi_no_config_response() + params = { + "auth_token": token, + } + params.update(json_payload) + return self._l10n_in_edi_connect_to_server( + company, + url_path="/iap/l10n_in_edi/1/getirnbydocdetails", + params=params, + ) + + @api.model + def _l10n_in_edi_cancel(self, company, json_payload): + token = self._l10n_in_edi_get_token(company) + if not token: + return self._l10n_in_edi_no_config_response() + params = { + "auth_token": token, + "json_payload": json_payload, + } + return self._l10n_in_edi_connect_to_server(company, url_path="/iap/l10n_in_edi/1/cancel", params=params) diff --git a/addons/l10n_in_edi/models/account_move.py b/addons/l10n_in_edi/models/account_move.py new file mode 100644 index 00000000000..65fefc5daa4 --- /dev/null +++ b/addons/l10n_in_edi/models/account_move.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class AccountMove(models.Model): + _inherit = "account.move" + + l10n_in_edi_cancel_reason = fields.Selection(selection=[ + ("1", "Duplicate"), + ("2", "Data Entry Mistake"), + ("3", "Order Cancelled"), + ("4", "Others"), + ], string="Cancel reason", copy=False) + l10n_in_edi_cancel_remarks = fields.Char("Cancel remarks", copy=False) + l10n_in_edi_show_cancel = fields.Boolean(compute="_compute_l10n_in_edi_show_cancel", string="E-invoice(IN) is sent?") + + @api.depends('edi_document_ids') + def _compute_l10n_in_edi_show_cancel(self): + for invoice in self: + invoice.l10n_in_edi_show_cancel = bool(invoice.edi_document_ids.filtered( + lambda i: i.edi_format_id.code == "in_einvoice_1_03" + and i.state in ("sent", "to_cancel", "cancelled") + )) + + def button_cancel_posted_moves(self): + """Mark the edi.document related to this move to be canceled.""" + reason_and_remarks_not_set = self.env["account.move"] + for move in self: + send_l10n_in_edi = move.edi_document_ids.filtered(lambda doc: doc.edi_format_id.code == "in_einvoice_1_03") + # check submitted E-invoice does not have reason and remarks + # because it's needed to cancel E-invoice + if send_l10n_in_edi and (not move.l10n_in_edi_cancel_reason or not move.l10n_in_edi_cancel_remarks): + reason_and_remarks_not_set += move + if reason_and_remarks_not_set: + raise UserError(_( + "To cancel E-invoice set cancel reason and remarks at Other info tab in invoices: \n%s", + ("\n".join(reason_and_remarks_not_set.mapped("name"))), + )) + return super().button_cancel_posted_moves() + + def _get_l10n_in_edi_response_json(self): + self.ensure_one() + l10n_in_edi = self.edi_document_ids.filtered(lambda i: i.edi_format_id.code == "in_einvoice_1_03" + and i.state in ("sent", "to_cancel")) + if l10n_in_edi: + return json.loads(l10n_in_edi.attachment_id.raw.decode("utf-8")) + else: + return {} diff --git a/addons/l10n_in_edi/models/res_company.py b/addons/l10n_in_edi/models/res_company.py new file mode 100644 index 00000000000..f35caca07b8 --- /dev/null +++ b/addons/l10n_in_edi/models/res_company.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + l10n_in_edi_username = fields.Char("E-invoice (IN) Username", groups="base.group_system") + l10n_in_edi_password = fields.Char("E-invoice (IN) Password", groups="base.group_system") + l10n_in_edi_token = fields.Char("E-invoice (IN) Token", groups="account.group_system") + l10n_in_edi_token_validity = fields.Datetime("E-invoice (IN) Valid Until", groups="account.group_system") + l10n_in_edi_production_env = fields.Boolean( + string="E-invoice (IN) Is production OSE environment", + help="Enable the use of production credentials", + groups="base.group_system", + ) + + def _l10n_in_edi_token_is_valid(self): + self.ensure_one() + if self.l10n_in_edi_token and self.l10n_in_edi_token_validity > fields.Datetime.now(): + return True + return False + + def _neutralize(self): + super()._neutralize() + self.flush() + self.invalidate_cache() + self.env.cr.execute("UPDATE res_company SET l10n_in_edi_production_env = true") diff --git a/addons/l10n_in_edi/models/res_config_settings.py b/addons/l10n_in_edi/models/res_config_settings.py new file mode 100644 index 00000000000..399bb3acc36 --- /dev/null +++ b/addons/l10n_in_edi/models/res_config_settings.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + l10n_in_edi_username = fields.Char("Indian EDI username", related="company_id.l10n_in_edi_username", readonly=False) + l10n_in_edi_password = fields.Char("Indian EDI password", related="company_id.l10n_in_edi_password", readonly=False) + l10n_in_edi_production_env = fields.Boolean( + string="Indian EDI Testing Environment", + related="company_id.l10n_in_edi_production_env", + readonly=False + ) + + def l10n_in_edi_test(self): + self.env["account.edi.format"]._l10n_in_edi_authenticate(self.company_id) diff --git a/addons/l10n_in_edi/tests/__init__.py b/addons/l10n_in_edi/tests/__init__.py new file mode 100644 index 00000000000..8511f06f2b5 --- /dev/null +++ b/addons/l10n_in_edi/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_edi_json diff --git a/addons/l10n_in_edi/tests/test_edi_json.py b/addons/l10n_in_edi/tests/test_edi_json.py new file mode 100644 index 00000000000..fe7ac0b6edf --- /dev/null +++ b/addons/l10n_in_edi/tests/test_edi_json.py @@ -0,0 +1,96 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged + + +@tagged("post_install_l10n", "post_install", "-at_install") +class TestEdiJson(AccountTestInvoicingCommon): + + + @classmethod + def setUpClass(cls, chart_template_ref="l10n_in.indian_chart_template_standard"): + super().setUpClass(chart_template_ref=chart_template_ref) + cls.maxDiff = None + cls.company_data["company"].write({ + "street": "Block no. 401", + "street2": "Street 2", + "city": "City 1", + "zip": "500001", + "state_id": cls.env.ref("base.state_in_ts").id, + "country_id": cls.env.ref("base.in").id, + "vat": "36AABCT1332L011", + }) + cls.partner_a.write({ + "vat": "36BBBFF5679L8ZR", + "street": "Block no. 401", + "street2": "Street 2", + "city": "City 2", + "zip": "500001", + "state_id": cls.env.ref("base.state_in_ts").id, + "country_id": cls.env.ref("base.in").id, + "l10n_in_gst_treatment": "regular", + }) + cls.product_a.write({"l10n_in_hsn_code": "01111"}) + gst_with_cess = cls.env.ref("l10n_in.%s_sgst_sale_12" % (cls.company_data["company"].id) + ) + cls.env.ref("l10n_in.%s_cess_5_plus_1591_sale" % (cls.company_data["company"].id)) + product_with_cess = cls.env["product.product"].create({ + "name": "product_with_cess", + "uom_id": cls.env.ref("uom.product_uom_unit").id, + "lst_price": 1000.0, + "standard_price": 800.0, + "property_account_income_id": cls.company_data["default_account_revenue"].id, + "property_account_expense_id": cls.company_data["default_account_expense"].id, + "taxes_id": [(6, 0, gst_with_cess.ids)], + "supplier_taxes_id": [(6, 0, cls.tax_purchase_a.ids)], + "l10n_in_hsn_code": "02222", + }) + cls.invoice = cls.init_invoice("out_invoice", post=False, products=cls.product_a + product_with_cess) + cls.invoice.write({ + "invoice_line_ids": [(1, l_id, {"discount": 10}) for l_id in cls.invoice.invoice_line_ids.ids]}) + cls.invoice.action_post() + + def test_edi_json(self): + json_value = self.env["account.edi.format"]._l10n_in_edi_generate_invoice_json(self.invoice) + expected = { + "Version": "1.1", + "TranDtls": {"TaxSch": "GST", "SupTyp": "B2B", "RegRev": "N", "IgstOnIntra": "N"}, + "DocDtls": {"Typ": "INV", "No": "INV/2019/00001", "Dt": "01/01/2019"}, + "SellerDtls": { + "LglNm": "company_1_data", + "Addr1": "Block no. 401", + "Addr2": "Street 2", + "Loc": "City 1", + "Pin": 500001, + "Stcd": "36", + "GSTIN": "36AABCT1332L011"}, + "BuyerDtls": { + "LglNm": "partner_a", + "Addr1": "Block no. 401", + "Addr2": "Street 2", + "Loc": "City 2", + "Pin": 500001, + "Stcd": "36", + "POS": "36", + "GSTIN": "36BBBFF5679L8ZR"}, + "ItemList": [ + { + "SlNo": "1", "PrdDesc": "product_a", "IsServc": "N", "HsnCd": "01111", "Qty": 1.0, + "Unit": "UNT", "UnitPrice": 1000.0, "TotAmt": 1000.0, "Discount": 100.0, "AssAmt": 900.0, + "GstRt": 5.0, "IgstAmt": 0.0, "CgstAmt": 22.5, "SgstAmt": 22.5, "CesRt": 0.0, "CesAmt": 0.0, + "CesNonAdvlAmt": 0.0, "StateCesRt": 0.0, "StateCesAmt": 0.0, "StateCesNonAdvlAmt": 0.0, + "OthChrg": 0.0, "TotItemVal": 945.0 + }, + { + "SlNo": "2", "PrdDesc": "product_with_cess", "IsServc": "N", "HsnCd": "02222", "Qty": 1.0, + "Unit": "UNT", "UnitPrice": 1000.0, "TotAmt": 1000.0, "Discount": 100.0, "AssAmt": 900.0, + "GstRt": 12.0, "IgstAmt": 0.0, "CgstAmt": 54.0, "SgstAmt": 54.0, "CesRt": 5.0, "CesAmt": 45.0, + "CesNonAdvlAmt": 1.59, "StateCesRt": 0.0, "StateCesAmt": 0.0, "StateCesNonAdvlAmt": 0.0, + "OthChrg": 0.0, "TotItemVal": 1054.59 + } + ], + "ValDtls": { + "AssVal": 1800.0, "CgstVal": 76.5, "SgstVal": 76.5, "IgstVal": 0.0, "CesVal": 46.59, + "StCesVal": 0.0, "RndOffAmt": 0.0, "TotInvVal": 1999.59 + } + } + self.assertDictEqual(json_value, expected, "Indian EDI send json value is not matched") diff --git a/addons/l10n_in_edi/views/account_move_views.xml b/addons/l10n_in_edi/views/account_move_views.xml new file mode 100644 index 00000000000..5780ec5f203 --- /dev/null +++ b/addons/l10n_in_edi/views/account_move_views.xml @@ -0,0 +1,15 @@ + + + + account.move.form.inherit.l10n.in.edi + account.move + + + + + + + + + + diff --git a/addons/l10n_in_edi/views/edi_pdf_report.xml b/addons/l10n_in_edi/views/edi_pdf_report.xml new file mode 100644 index 00000000000..a528cadf903 --- /dev/null +++ b/addons/l10n_in_edi/views/edi_pdf_report.xml @@ -0,0 +1,31 @@ + + + + diff --git a/addons/l10n_in_edi/views/res_config_settings_views.xml b/addons/l10n_in_edi/views/res_config_settings_views.xml new file mode 100644 index 00000000000..42e54b5b6f7 --- /dev/null +++ b/addons/l10n_in_edi/views/res_config_settings_views.xml @@ -0,0 +1,40 @@ + + + + res.config.settings.form.inherit.l10n_in_edi + res.config.settings + + +
+

Indian Electronic Invoicing

+
+
+
+ Setup E-invoice + +
+ Setup E-invoice Service for this company +
+
+
+
+
+
+
+
+
+
+
+
+
+