Initial commit: L10N_Asia Pacific packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:52 +02:00
commit 54c86b612c
828 changed files with 58224 additions and 0 deletions

View file

@ -0,0 +1,60 @@
# Indian - E-invoicing
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 -> Configuration -> 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: <https://service.odoo.co.in/einvoice_create_api_user>
## Installation
```bash
pip install odoo-bringout-oca-ocb-l10n_in_edi
```
## Dependencies
This addon depends on:
- account_edi
- l10n_in
- iap
## Manifest Information
- **Name**: Indian - E-invoicing
- **Version**: 1.03.00
- **Category**: Accounting/Localizations/EDI
- **License**: LGPL-3
- **Installable**: True
## Source
Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `l10n_in_edi`.
## License
This package maintains the original LGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Reports: doc/REPORTS.md
- Security: doc/SECURITY.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md

View file

@ -0,0 +1,32 @@
# Architecture
```mermaid
flowchart TD
U[Users] -->|HTTP| V[Views and QWeb Templates]
V --> C[Controllers]
V --> W[Wizards Transient Models]
C --> M[Models and ORM]
W --> M
M --> R[Reports]
DX[Data XML] --> M
S[Security ACLs and Groups] -. enforces .-> M
subgraph L10n_in_edi Module - l10n_in_edi
direction LR
M:::layer
W:::layer
C:::layer
V:::layer
R:::layer
S:::layer
DX:::layer
end
classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px
```
Notes
- Views include tree/form/kanban templates and report templates.
- Controllers provide website/portal routes when present.
- Wizards are UI flows implemented with `models.TransientModel`.
- Data XML loads data/demo records; Security defines groups and access.

View file

@ -0,0 +1,3 @@
# Configuration
Refer to Odoo settings for l10n_in_edi. Configure related models, access rights, and options as needed.

View file

@ -0,0 +1,3 @@
# Controllers
This module does not define custom HTTP controllers.

View file

@ -0,0 +1,7 @@
# Dependencies
This addon depends on:
- [account_edi](../../odoo-bringout-oca-ocb-account_edi)
- [l10n_in](../../odoo-bringout-oca-ocb-l10n_in)
- [iap](../../odoo-bringout-oca-ocb-iap)

View file

@ -0,0 +1,4 @@
# FAQ
- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged).
- Q: How to enable? A: Start server with --addon l10n_in_edi or install in UI.

View file

@ -0,0 +1,7 @@
# Install
```bash
pip install odoo-bringout-oca-ocb-l10n_in_edi"
# or
uv pip install odoo-bringout-oca-ocb-l10n_in_edi"
```

View file

@ -0,0 +1,15 @@
# Models
Detected core models and extensions in l10n_in_edi.
```mermaid
classDiagram
class account_edi_format
class account_move
class res_company
class res_config_settings
```
Notes
- Classes show model technical names; fields omitted for brevity.
- Items listed under _inherit are extensions of existing models.

View file

@ -0,0 +1,6 @@
# Overview
Packaged Odoo addon: l10n_in_edi. Provides features documented in upstream Odoo 16 under this addon.
- Source: OCA/OCB 16.0, addon l10n_in_edi
- License: LGPL-3

View file

@ -0,0 +1,3 @@
# Reports
This module does not define custom reports.

View file

@ -0,0 +1,8 @@
# Security
This module does not define custom security rules or access controls beyond Odoo defaults.
Default Odoo security applies:
- Base user access through standard groups
- Model access inherited from dependencies
- No custom row-level security rules

View file

@ -0,0 +1,5 @@
# Troubleshooting
- Ensure Python and Odoo environment matches repo guidance.
- Check database connectivity and logs if startup fails.
- Validate that dependent addons listed in DEPENDENCIES.md are installed.

View file

@ -0,0 +1,7 @@
# Usage
Start Odoo including this addon (from repo root):
```bash
python3 scripts/nix_odoo_web_server.py --db-name mydb --addon l10n_in_edi
```

View file

@ -0,0 +1,3 @@
# Wizards
This module does not include UI wizards.

View file

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models

View file

@ -0,0 +1,38 @@
# -*- 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 -> Configuration -> 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: <https://service.odoo.co.in/einvoice_create_api_user>
""",
"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
"license": "LGPL-3",
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="edi_in_einvoice_json_1_03" model="account.edi.format">
<field name="name">E-Invoice (IN)</field>
<field name="code">in_einvoice_1_03</field>
</record>
</odoo>

View file

@ -0,0 +1,7 @@
-- disable l10n_in_edi integration
UPDATE res_company
SET l10n_in_edi_production_env = false,
l10n_in_edi_username = NULL,
l10n_in_edi_password = NULL,
l10n_in_edi_token = NULL,
l10n_in_edi_token_validity = NULL;

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- This is testing credentials -->
<record id="l10n_in.demo_company_in" model="res.company">
<field name="l10n_in_edi_username">MGSTTEST</field>
<field name="l10n_in_edi_password">mgst@123</field>
</record>
</odoo>

View file

@ -0,0 +1,411 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * l10n_in_edi
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-08-20 11:49+0000\n"
"PO-Revision-Date: 2025-08-20 11:49+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid "- City required min 3 and max 100 characters"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid "- Email address should be valid and not more then 100 characters"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid "- Mobile number should be minimum 10 or maximum 12 digits"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid "- State TIN Number must be exactly 2 digits."
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid "- State required min 3 and max 50 characters"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid "- Street required min 3 and max 100 characters"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid "- Street2 should be min 3 and max 100 characters"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid "- Zip code required 6 digits"
msgstr ""
#. module: l10n_in_edi
#: model_terms:ir.ui.view,arch_db:l10n_in_edi.res_config_settings_view_form_inherit_l10n_in_edi
msgid ""
"<span class=\"o_form_label\">Setup E-invoice</span>\n"
" <span class=\"fa fa-lg fa-building-o\" title=\"Values set here are company-specific.\" aria-label=\"Values set here are company-specific.\" groups=\"base.group_multi_company\" role=\"img\"/>"
msgstr ""
#. module: l10n_in_edi
#: model_terms:ir.ui.view,arch_db:l10n_in_edi.l10n_in_einvoice_report_invoice_document_inherit
msgid "<strong>Acknowledgement:</strong>"
msgstr ""
#. module: l10n_in_edi
#: model_terms:ir.ui.view,arch_db:l10n_in_edi.l10n_in_einvoice_report_invoice_document_inherit
msgid "<strong>IRN:</strong>"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/res_config_settings.py:0
#, python-format
msgid "API credentials validated successfully"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid "Buy Credits"
msgstr ""
#. module: l10n_in_edi
#: model_terms:ir.ui.view,arch_db:l10n_in_edi.res_config_settings_view_form_inherit_l10n_in_edi
msgid "Buy credits"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model.fields,field_description:l10n_in_edi.field_account_bank_statement_line__l10n_in_edi_cancel_reason
#: model:ir.model.fields,field_description:l10n_in_edi.field_account_move__l10n_in_edi_cancel_reason
#: model:ir.model.fields,field_description:l10n_in_edi.field_account_payment__l10n_in_edi_cancel_reason
msgid "Cancel reason"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model.fields,field_description:l10n_in_edi.field_account_bank_statement_line__l10n_in_edi_cancel_remarks
#: model:ir.model.fields,field_description:l10n_in_edi.field_account_move__l10n_in_edi_cancel_remarks
#: model:ir.model.fields,field_description:l10n_in_edi.field_account_payment__l10n_in_edi_cancel_remarks
msgid "Cancel remarks"
msgstr ""
#. module: l10n_in_edi
#: model_terms:ir.ui.view,arch_db:l10n_in_edi.res_config_settings_view_form_inherit_l10n_in_edi
msgid "Check the"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model,name:l10n_in_edi.model_res_company
msgid "Companies"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model,name:l10n_in_edi.model_res_config_settings
msgid "Config Settings"
msgstr ""
#. module: l10n_in_edi
#: model_terms:ir.ui.view,arch_db:l10n_in_edi.res_config_settings_view_form_inherit_l10n_in_edi
msgid ""
"Costs 1 credit per transaction. Free 200 credits will be available for the "
"first time."
msgstr ""
#. module: l10n_in_edi
#: model:ir.model.fields.selection,name:l10n_in_edi.selection__account_move__l10n_in_edi_cancel_reason__2
msgid "Data Entry Mistake"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model.fields.selection,name:l10n_in_edi.selection__account_move__l10n_in_edi_cancel_reason__1
msgid "Duplicate"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model.fields,field_description:l10n_in_edi.field_res_company__l10n_in_edi_production_env
msgid "E-invoice (IN) Is production OSE environment"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model.fields,field_description:l10n_in_edi.field_res_company__l10n_in_edi_password
msgid "E-invoice (IN) Password"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model.fields,field_description:l10n_in_edi.field_res_company__l10n_in_edi_token
msgid "E-invoice (IN) Token"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model.fields,field_description:l10n_in_edi.field_res_company__l10n_in_edi_username
msgid "E-invoice (IN) Username"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model.fields,field_description:l10n_in_edi.field_res_company__l10n_in_edi_token_validity
msgid "E-invoice (IN) Valid Until"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model.fields,field_description:l10n_in_edi.field_account_bank_statement_line__l10n_in_edi_show_cancel
#: model:ir.model.fields,field_description:l10n_in_edi.field_account_move__l10n_in_edi_show_cancel
#: model:ir.model.fields,field_description:l10n_in_edi.field_account_payment__l10n_in_edi_show_cancel
msgid "E-invoice(IN) is sent?"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model,name:l10n_in_edi.model_account_edi_format
msgid "EDI format"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model.fields,help:l10n_in_edi.field_res_company__l10n_in_edi_production_env
#: model:ir.model.fields,help:l10n_in_edi.field_res_config_settings__l10n_in_edi_production_env
msgid "Enable the use of production credentials"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid "Ensure GST Number set on company setting and API are Verified."
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/res_config_settings.py:0
#, python-format
msgid "Go to Company"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid "HSN code is not set in product %s"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/res_config_settings.py:0
#, python-format
msgid ""
"Incorrect username or password, or the GST number on company does not match."
msgstr ""
#. module: l10n_in_edi
#: model:ir.model.fields,field_description:l10n_in_edi.field_res_config_settings__l10n_in_edi_production_env
msgid "Indian EDI Testing Environment"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model.fields,field_description:l10n_in_edi.field_res_config_settings__l10n_in_edi_password
msgid "Indian EDI password"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model.fields,field_description:l10n_in_edi.field_res_config_settings__l10n_in_edi_username
msgid "Indian EDI username"
msgstr ""
#. module: l10n_in_edi
#: model_terms:ir.ui.view,arch_db:l10n_in_edi.res_config_settings_view_form_inherit_l10n_in_edi
msgid "Indian Electronic Invoicing"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid "Invalid HSN Code (%s) in product %s"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid ""
"Invoice lines having a negative amount are not allowed to generate the IRN. "
"Please create a credit note instead."
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid "Invoice number should not be more than 16 characters"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model,name:l10n_in_edi.model_account_move
msgid "Journal Entry"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid "Negative discount is not allowed, set in line %s"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model.fields.selection,name:l10n_in_edi.selection__account_move__l10n_in_edi_cancel_reason__3
msgid "Order Cancelled"
msgstr ""
#. module: l10n_in_edi
#: model:ir.model.fields.selection,name:l10n_in_edi.selection__account_move__l10n_in_edi_cancel_reason__4
msgid "Others"
msgstr ""
#. module: l10n_in_edi
#: model_terms:ir.ui.view,arch_db:l10n_in_edi.res_config_settings_view_form_inherit_l10n_in_edi
msgid "Password"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid "Please buy more credits and retry: "
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/res_config_settings.py:0
#, python-format
msgid "Please enter a GST number in company."
msgstr ""
#. module: l10n_in_edi
#: model_terms:ir.ui.view,arch_db:l10n_in_edi.res_config_settings_view_form_inherit_l10n_in_edi
msgid "Production Environment"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_move.py:0
#, python-format
msgid "Retrying EDI processing for the following documents:%s"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid ""
"Set an appropriate GST tax on line \"%s\" (if it's zero rated or nil rated "
"then select it also)"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid ""
"Somehow this invoice had been cancelled to government before.<br/>Normally, "
"this should not happen too often<br/>Just verify by logging into government "
"website <a href='https://einvoice1.gst.gov.in'>here<a>."
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid ""
"Somehow this invoice had been submited to government before.<br/>Normally, "
"this should not happen too often<br/>Just verify value of invoice by uploade"
" json to government website <a "
"href='https://einvoice1.gst.gov.in/Others/VSignedInvoice'>here<a>."
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_move.py:0
#, python-format
msgid ""
"To cancel E-invoice set cancel reason and remarks at Other info tab in invoices: \n"
"%s"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid ""
"Unable to connect to the online E-invoice service.The web service may be "
"temporary down. Please try again in a moment."
msgstr ""
#. module: l10n_in_edi
#: model_terms:ir.ui.view,arch_db:l10n_in_edi.res_config_settings_view_form_inherit_l10n_in_edi
msgid "Username"
msgstr ""
#. module: l10n_in_edi
#: model_terms:ir.ui.view,arch_db:l10n_in_edi.res_config_settings_view_form_inherit_l10n_in_edi
msgid "Verify Username and Password"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid "You have insufficient credits to send this document!"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/res_config_settings.py:0
#, python-format
msgid "You must enable production environment to buy credits"
msgstr ""
#. module: l10n_in_edi
#: model_terms:ir.ui.view,arch_db:l10n_in_edi.res_config_settings_view_form_inherit_l10n_in_edi
msgid "documentation"
msgstr ""
#. module: l10n_in_edi
#. odoo-python
#: code:addons/l10n_in_edi/models/account_edi_format.py:0
#, python-format
msgid "product is required to get HSN code"
msgstr ""
#. module: l10n_in_edi
#: model_terms:ir.ui.view,arch_db:l10n_in_edi.res_config_settings_view_form_inherit_l10n_in_edi
msgid "to get credentials"
msgstr ""

View file

@ -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

View file

@ -0,0 +1,736 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
import json
import pytz
import markupsafe
from collections import defaultdict
from odoo import models, fields, api, _
from odoo.tools import html_escape, float_is_zero, float_compare
from odoo.exceptions import AccessError, ValidationError
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 _get_l10n_in_base_tags(self):
return (
self.env.ref('l10n_in.tax_tag_base_sgst').ids
+ self.env.ref('l10n_in.tax_tag_base_cgst').ids
+ self.env.ref('l10n_in.tax_tag_base_igst').ids
+ self.env.ref('l10n_in.tax_tag_base_cess').ids
+ self.env.ref('l10n_in.tax_tag_zero_rated').ids
+ self.env.ref("l10n_in.tax_tag_exempt").ids
+ self.env.ref("l10n_in.tax_tag_nil_rated").ids
+ self.env.ref("l10n_in.tax_tag_non_gst_supplies").ids
)
def _get_l10n_in_gst_tags(self):
return (
self.env.ref('l10n_in.tax_tag_base_sgst')
+ self.env.ref('l10n_in.tax_tag_base_cgst')
+ self.env.ref('l10n_in.tax_tag_base_igst')
+ self.env.ref('l10n_in.tax_tag_base_cess')
+ self.env.ref('l10n_in.tax_tag_zero_rated')
).ids
def _get_l10n_in_non_taxable_tags(self):
return (
self.env.ref("l10n_in.tax_tag_exempt")
+ self.env.ref("l10n_in.tax_tag_nil_rated")
+ self.env.ref("l10n_in.tax_tag_non_gst_supplies")
).ids
def _get_move_applicability(self, move):
# EXTENDS account_edi
self.ensure_one()
if self.code != 'in_einvoice_1_03':
return super()._get_move_applicability(move)
is_under_gst = any(move_line_tag.id in self._get_l10n_in_gst_tags() for move_line_tag in move.line_ids.tax_tag_ids)
if move.is_sale_document(include_receipts=True) and move.country_code == 'IN' and is_under_gst and move.l10n_in_gst_treatment in (
"regular",
"composition",
"overseas",
"special_economic_zone",
"deemed_export",
):
return {
'post': self._l10n_in_edi_post_invoice,
'cancel': self._l10n_in_edi_cancel_invoice,
'edi_content': self._l10n_in_edi_xml_invoice_content,
}
def _needs_web_services(self):
self.ensure_one()
return self.code == "in_einvoice_1_03" or super()._needs_web_services()
def _l10n_in_edi_xml_invoice_content(self, invoice):
return json.dumps(self._l10n_in_edi_generate_invoice_json(invoice)).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"))
all_base_tags = self._get_l10n_in_gst_tags() + self._get_l10n_in_non_taxable_tags()
for line in move.invoice_line_ids.filtered(lambda line: line.display_type not in ('line_note', 'line_section', 'rounding')):
if line.price_subtotal < 0:
# Line having a negative amount is not allowed.
if not move._l10n_in_edi_is_managing_invoice_negative_lines_allowed():
raise ValidationError(_("Invoice lines having a negative amount are not allowed to generate the IRN. "
"Please create a credit note instead."))
if line.display_type == 'product' and line.discount < 0:
error_message.append(_("Negative discount is not allowed, set in line %s", line.name))
if not line.tax_tag_ids or not any(move_line_tag.id in all_base_tags for move_line_tag in line.tax_tag_ids):
error_message.append(_(
"""Set an appropriate GST tax on line "%s" (if it's zero rated or nil rated then select it also)""", line.product_id.name))
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):
url = self.env["iap.account"].get_credits_url(service_name="l10n_in_edi")
return markupsafe.Markup("""<p><b>%s</b></p><p>%s <a href="%s">%s</a></p>""") % (
_("You have insufficient credits to send this document!"),
_("Please buy more credits and retry: "),
url,
_("Buy Credits")
)
def _l10n_in_edi_post_invoice(self, invoice):
generate_json = self._l10n_in_edi_generate_invoice_json(invoice)
response = self._l10n_in_edi_generate(invoice.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(invoice.company_id)
if not authenticate_response.get("error"):
error = []
response = self._l10n_in_edi_generate(invoice.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(invoice.company_id, {
"doc_type": invoice.move_type == "out_refund" and "CRN" or "INV",
"doc_num": invoice.name,
"doc_date": invoice.invoice_date and invoice.invoice_date.strftime("%d/%m/%Y") or False,
})
if not response.get("error"):
error = []
odoobot = self.env.ref("base.partner_root")
invoice.message_post(author_id=odoobot.id, body=_(
"Somehow this invoice had been submited to government before." \
"<br/>Normally, this should not happen too often" \
"<br/>Just verify value of invoice by uploade json to government website " \
"<a href='https://einvoice1.gst.gov.in/Others/VSignedInvoice'>here<a>."
))
if "no-credit" in error_codes:
return {invoice: {
"success": False,
"error": self._l10n_in_edi_get_iap_buy_credits_message(invoice.company_id),
"blocking_level": "error",
}}
elif error:
error_message = "<br/>".join(["[%s] %s" % (e.get("code"), html_escape(e.get("message"))) for e in error])
return {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_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",
})
return {invoice: {"success": True, "attachment": attachment}}
def _l10n_in_edi_cancel_invoice(self, invoice):
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 = {}
error = []
odoobot = self.env.ref("base.partner_root")
invoice.message_post(author_id=odoobot.id, body=_(
"Somehow this invoice had been cancelled to government before." \
"<br/>Normally, this should not happen too often" \
"<br/>Just verify by logging into government website " \
"<a href='https://einvoice1.gst.gov.in'>here<a>."
))
if "no-credit" in error_codes:
return {invoice: {
"success": False,
"error": self._l10n_in_edi_get_iap_buy_credits_message(invoice.company_id),
"blocking_level": "error",
}}
if error:
error_message = "<br/>".join(["[%s] %s" % (e.get("code"), html_escape(e.get("message"))) for e in error])
return {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 = False
if json_dump:
attachment = self.env["ir.attachment"].create({
"name": json_name,
"raw": json_dump.encode(),
"res_model": "account.move",
"res_id": invoice.id,
"mimetype": "application/json",
})
return {invoice: {"success": True, "attachment": attachment}}
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(_("- Street required min 3 and max 100 characters"))
if partner.street2 and not re.match("^.{3,100}$", partner.street2):
message.append(_("- Street2 should be min 3 and max 100 characters"))
if not re.match("^.{3,100}$", partner.city or ""):
message.append(_("- City required min 3 and max 100 characters"))
if partner.country_id.code == "IN" and not re.match("^.{3,50}$", partner.state_id.name or ""):
message.append(_("- State required min 3 and max 50 characters"))
if (
partner.country_id.code == "IN"
and not re.match(r"^(?!0+$)([0-9]{2})$", partner.state_id.l10n_in_tin or "")
):
message.append(_("- State TIN Number must be exactly 2 digits."))
if partner.country_id.code == "IN" and not re.match("^[0-9]{6,}$", partner.zip or ""):
message.append(_("- 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(_("- 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(_("- Email address should be valid and not more then 100 characters"))
if message:
message.insert(0, "%s" %(partner.display_name))
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
"""
zip_digits = self._l10n_in_edi_extract_digits(partner.zip)
partner_details = {
"Addr1": partner.street or "",
"Loc": partner.city or "",
"Pin": zip_digits and int(zip_digits) or "",
"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 "URP",
})
else:
partner_details.update({"Nm": partner.name or partner.commercial_partner_id.name})
# For no country I would suppose it is India, so not sure this is super right
if is_overseas and (not partner.country_id or partner.country_id.code != 'IN'):
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
"""
value = round(amount, precision_digits)
# avoid -0.0
return value if value else 0.0
def _get_l10n_in_edi_line_details(self, index, line, line_tax_details):
"""
Create the dictionary with line details
return {
account.move.line('1'): {....},
account.move.line('2'): {....},
....
}
"""
sign = line.move_id.is_inbound() and -1 or 1
tax_details_by_code = self._get_l10n_in_tax_details_by_line_code(line_tax_details.get("tax_details", {}))
quantity = line.quantity
full_discount_or_zero_quantity = line.discount == 100.00 or float_is_zero(quantity, 3)
if full_discount_or_zero_quantity:
unit_price_in_inr = line.currency_id._convert(
line.price_unit,
line.company_currency_id,
line.company_id,
line.date or fields.Date.context_today(self)
)
else:
unit_price_in_inr = ((sign * line.balance) / (1 - (line.discount / 100))) / quantity
if unit_price_in_inr < 0 and quantity < 0:
# If unit price and quantity both is negative then
# We set unit price and quantity as positive because
# government does not accept negative in qty or unit price
unit_price_in_inr = unit_price_in_inr * -1
quantity = quantity * -1
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(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(unit_price_in_inr, 3),
# total amount is before discount
"TotAmt": self._l10n_in_round_value(unit_price_in_inr * quantity),
"Discount": self._l10n_in_round_value((unit_price_in_inr * quantity) * (line.discount / 100)),
"AssAmt": self._l10n_in_round_value((sign * line.balance)),
"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)),
"CgstAmt": self._l10n_in_round_value(tax_details_by_code.get("cgst_amount", 0.00)),
"SgstAmt": self._l10n_in_round_value(tax_details_by_code.get("sgst_amount", 0.00)),
"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)),
"CesNonAdvlAmt": self._l10n_in_round_value(
tax_details_by_code.get("cess_non_advol_amount", 0.00)),
"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)),
"StateCesNonAdvlAmt": self._l10n_in_round_value(
tax_details_by_code.get("state_cess_non_advol_amount", 0.00)),
"OthChrg": self._l10n_in_round_value(tax_details_by_code.get("other_amount", 0.00)),
"TotItemVal": self._l10n_in_round_value(((sign * line.balance) + line_tax_details.get("tax_amount", 0.00))),
}
def _l10n_in_edi_generate_invoice_json_managing_negative_lines(self, invoice, json_payload):
"""Set negative lines against positive lines as discount with same HSN code and tax rate
With negative lines
product name | hsn code | unit price | qty | discount | total
=============================================================
product A | 123456 | 1000 | 1 | 100 | 900
product B | 123456 | 1500 | 2 | 0 | 3000
Discount | 123456 | -300 | 1 | 0 | -300
Converted to without negative lines
product name | hsn code | unit price | qty | discount | total
=============================================================
product A | 123456 | 1000 | 1 | 100 | 900
product B | 123456 | 1500 | 2 | 300 | 2700
totally discounted lines are kept as 0, though
"""
def discount_group_key(line_vals):
return "%s-%s"%(line_vals['HsnCd'], line_vals['GstRt'])
def put_discount_on(discount_line_vals, other_line_vals):
discount = discount_line_vals['AssAmt'] * -1
discount_to_allow = other_line_vals['AssAmt']
if float_compare(discount_to_allow, discount, precision_rounding=invoice.currency_id.rounding) < 0:
# Update discount line, needed when discount is more then max line, in short remaining_discount is not zero
discount_line_vals.update({
'AssAmt': self._l10n_in_round_value(discount_line_vals['AssAmt'] + other_line_vals['AssAmt']),
'IgstAmt': self._l10n_in_round_value(discount_line_vals['IgstAmt'] + other_line_vals['IgstAmt']),
'CgstAmt': self._l10n_in_round_value(discount_line_vals['CgstAmt'] + other_line_vals['CgstAmt']),
'SgstAmt': self._l10n_in_round_value(discount_line_vals['SgstAmt'] + other_line_vals['SgstAmt']),
'CesAmt': self._l10n_in_round_value(discount_line_vals['CesAmt'] + other_line_vals['CesAmt']),
'CesNonAdvlAmt': self._l10n_in_round_value(discount_line_vals['CesNonAdvlAmt'] + other_line_vals['CesNonAdvlAmt']),
'StateCesAmt': self._l10n_in_round_value(discount_line_vals['StateCesAmt'] + other_line_vals['StateCesAmt']),
'StateCesNonAdvlAmt': self._l10n_in_round_value(discount_line_vals['StateCesNonAdvlAmt'] + other_line_vals['StateCesNonAdvlAmt']),
'OthChrg': self._l10n_in_round_value(discount_line_vals['OthChrg'] + other_line_vals['OthChrg']),
'TotItemVal': self._l10n_in_round_value(discount_line_vals['TotItemVal'] + other_line_vals['TotItemVal']),
})
other_line_vals.update({
'Discount': self._l10n_in_round_value(other_line_vals['Discount'] + discount_to_allow),
'AssAmt': 0.00,
'IgstAmt': 0.00,
'CgstAmt': 0.00,
'SgstAmt': 0.00,
'CesAmt': 0.00,
'CesNonAdvlAmt': 0.00,
'StateCesAmt': 0.00,
'StateCesNonAdvlAmt': 0.00,
'OthChrg': 0.00,
'TotItemVal': 0.00,
})
return False
other_line_vals.update({
'Discount': self._l10n_in_round_value(other_line_vals['Discount'] + discount),
'AssAmt': self._l10n_in_round_value(other_line_vals['AssAmt'] + discount_line_vals['AssAmt']),
'IgstAmt': self._l10n_in_round_value(other_line_vals['IgstAmt'] + discount_line_vals['IgstAmt']),
'CgstAmt': self._l10n_in_round_value(other_line_vals['CgstAmt'] + discount_line_vals['CgstAmt']),
'SgstAmt': self._l10n_in_round_value(other_line_vals['SgstAmt'] + discount_line_vals['SgstAmt']),
'CesAmt': self._l10n_in_round_value(other_line_vals['CesAmt'] + discount_line_vals['CesAmt']),
'CesNonAdvlAmt': self._l10n_in_round_value(other_line_vals['CesNonAdvlAmt'] + discount_line_vals['CesNonAdvlAmt']),
'StateCesAmt': self._l10n_in_round_value(other_line_vals['StateCesAmt'] + discount_line_vals['StateCesAmt']),
'StateCesNonAdvlAmt': self._l10n_in_round_value(other_line_vals['StateCesNonAdvlAmt'] + discount_line_vals['StateCesNonAdvlAmt']),
'OthChrg': self._l10n_in_round_value(other_line_vals['OthChrg'] + discount_line_vals['OthChrg']),
'TotItemVal': self._l10n_in_round_value(other_line_vals['TotItemVal'] + discount_line_vals['TotItemVal']),
})
return True
discount_lines = []
for discount_line in json_payload['ItemList'].copy(): #to be sure to not skip in the loop:
if discount_line['AssAmt'] < 0:
discount_lines.append(discount_line)
json_payload['ItemList'].remove(discount_line)
if not discount_lines:
return json_payload
lines_grouped_and_sorted = defaultdict(list)
for line in sorted(json_payload['ItemList'], key=lambda i: i['AssAmt'], reverse=True):
lines_grouped_and_sorted[discount_group_key(line)].append(line)
for discount_line in discount_lines:
apply_discount_on_lines = lines_grouped_and_sorted.get(discount_group_key(discount_line), [])
for apply_discount_on in apply_discount_on_lines:
if put_discount_on(discount_line, apply_discount_on):
break
return json_payload
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", {}))
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: line.display_type not in ('line_note', 'line_section', 'rounding'))
tax_details_per_record = tax_details.get("tax_details_per_record")
sign = invoice.is_inbound() and -1 or 1
rounding_amount = sum(line.balance for line in invoice.line_ids if line.display_type == 'rounding') * sign
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_amount") 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, tax_details_per_record.get(line, {}))
for index, line in enumerate(lines, start=1)
],
"ValDtls": {
"AssVal": self._l10n_in_round_value(tax_details.get("base_amount")),
"CgstVal": self._l10n_in_round_value(tax_details_by_code.get("cgst_amount", 0.00)),
"SgstVal": self._l10n_in_round_value(tax_details_by_code.get("sgst_amount", 0.00)),
"IgstVal": self._l10n_in_round_value(tax_details_by_code.get("igst_amount", 0.00)),
"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)),
),
"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)),
),
"RndOffAmt": self._l10n_in_round_value(
rounding_amount),
"TotInvVal": self._l10n_in_round_value(
(tax_details.get("base_amount") + tax_details.get("tax_amount") + rounding_amount)),
},
}
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")))
})
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"), is_overseas=is_overseas)
})
if is_overseas:
json_payload.update({
"ExpDtls": {
"RefClm": tax_details_by_code.get("igst_amount") and "Y" or "N",
"ForCur": invoice.currency_id.name,
"CntCode": saler_buyer.get("buyer_details").country_id.code or "",
}
})
if invoice.l10n_in_shipping_bill_number:
json_payload["ExpDtls"].update({
"ShipBNo": invoice.l10n_in_shipping_bill_number,
})
if invoice.l10n_in_shipping_bill_date:
json_payload["ExpDtls"].update({
"ShipBDt": invoice.l10n_in_shipping_bill_date.strftime("%d/%m/%Y"),
})
if invoice.l10n_in_shipping_port_code_id:
json_payload["ExpDtls"].update({
"Port": invoice.l10n_in_shipping_port_code_id.code
})
if not invoice._l10n_in_edi_is_managing_invoice_negative_lines_allowed():
return json_payload
return self._l10n_in_edi_generate_invoice_json_managing_negative_lines(invoice, json_payload)
@api.model
def _l10n_in_prepare_edi_tax_details(self, move, in_foreign=False, filter_invl_to_apply=None):
def l10n_in_grouping_key_generator(base_line, tax_values):
invl = base_line['record']
tax = tax_values['tax_repartition_line'].tax_id
tags = tax_values['tax_repartition_line'].tag_ids
line_code = "other"
if not invl.currency_id.is_zero(tax_values['tax_amount_currency']):
if any(tag in tags for tag in self.env.ref("l10n_in.tax_tag_cess")):
if tax.amount_type != "percent":
line_code = "cess_non_advol"
else:
line_code = "cess"
elif any(tag in tags for tag in self.env.ref("l10n_in.tax_tag_state_cess")):
if tax.amount_type != "percent":
line_code = "state_cess_non_advol"
else:
line_code = "state_cess"
else:
for gst in ["cgst", "sgst", "igst"]:
if any(tag in tags for tag in self.env.ref("l10n_in.tax_tag_%s"%(gst))):
line_code = gst
return {
"tax": tax,
"base_product_id": invl.product_id,
"tax_product_id": invl.product_id,
"base_product_uom_id": invl.product_uom_id,
"tax_product_uom_id": invl.product_uom_id,
"line_code": line_code,
}
def l10n_in_filter_to_apply(base_line, tax_values):
if base_line['record'].display_type == 'rounding':
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,
filter_invl_to_apply=filter_invl_to_apply,
)
@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': '0',
'message': _(
"Ensure GST Number set on company setting and API are Verified."
)}
]}
@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:
tz = pytz.timezone("Asia/Kolkata")
local_time = tz.localize(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)

View file

@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from markupsafe import Markup
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 action_retry_edi_documents_error(self):
for move in self:
if move.country_code == 'IN':
move.message_post(body=_(
"Retrying EDI processing for the following documents: %(breakline)s %(edi_codes)s",
breakline=Markup("<br/>"),
edi_codes=Markup("<br/>").join(
move.edi_document_ids
.filtered(lambda doc: doc.blocking_level == "error")
.mapped("edi_format_name")
)
))
return super().action_retry_edi_documents_error()
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.sudo().attachment_id.raw.decode("utf-8"))
else:
return {}
@api.model
def _l10n_in_edi_is_managing_invoice_negative_lines_allowed(self):
""" Negative lines are not allowed by the Indian government making some features unavailable like sale_coupon
or global discounts. This method allows odoo to distribute the negative discount lines to each others lines
with same HSN code making such features available even for Indian people.
:return: True if odoo needs to distribute the negative discount lines, False otherwise.
"""
param_name = 'l10n_in_edi.manage_invoice_negative_lines'
return bool(self.env['ir.config_parameter'].sudo().get_param(param_name))

View file

@ -0,0 +1,24 @@
# -*- 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="base.group_system")
l10n_in_edi_token_validity = fields.Datetime("E-invoice (IN) Valid Until", groups="base.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

View file

@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields, _
from odoo.exceptions import UserError, RedirectWarning
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_check_gst_number(self):
if not self.company_id.vat:
action = {
"view_mode": "form",
"res_model": "res.company",
"type": "ir.actions.act_window",
"res_id" : self.company_id.id,
"views": [[self.env.ref("base.view_company_form").id, "form"]],
}
raise RedirectWarning(_("Please enter a GST number in company."), action, _('Go to Company'))
def l10n_in_edi_test(self):
self.l10n_in_check_gst_number()
response = self.env['account.edi.format']._l10n_in_edi_authenticate(self.company_id)
if response.get('error'):
raise UserError("\n".join(["[%s] %s" % (e.get('code'), (e.get('message'))) for e in response['error']]))
elif not self.company_id.sudo()._l10n_in_edi_token_is_valid():
raise UserError(_("Incorrect username or password, or the GST number on company does not match."))
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'info',
'sticky': False,
'message': _("API credentials validated successfully"),
}
}
def l10n_in_edi_buy_iap(self):
if not self.l10n_in_edi_production_env:
raise UserError(_("You must enable production environment to buy credits"))
return {
'type': 'ir.actions.act_url',
'url': self.env["iap.account"].get_credits_url(service_name="l10n_in_edi", base_url=''),
'target': '_new'
}

View file

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_edi_json

View file

@ -0,0 +1,421 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
from freezegun import freeze_time
@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.env['ir.config_parameter'].set_param('l10n_in_edi.manage_invoice_negative_lines', True)
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"})
cls.product_a2 = cls.env['product.product'].create({
'name': 'product_a2',
'uom_id': cls.env.ref('uom.product_uom_unit').id,
'lst_price': 1000.0,
'standard_price': 1000.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, cls.tax_sale_a.ids)],
'supplier_taxes_id': [(6, 0, cls.tax_purchase_a.ids)],
"l10n_in_hsn_code": "01111",
})
cls.product_a_discount = cls.env['product.product'].create({
'name': 'product_a discount',
'uom_id': cls.env.ref('uom.product_uom_unit').id,
'lst_price': 400.0,
'standard_price': 400.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, cls.tax_sale_a.ids)],
'supplier_taxes_id': [(6, 0, cls.tax_purchase_a.ids)],
"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",
})
rounding = cls.env["account.cash.rounding"].create({
"name": "half-up",
"rounding": 1.0,
"strategy": "add_invoice_line",
"profit_account_id": cls.company_data['default_account_expense'].id,
"loss_account_id": cls.company_data['default_account_expense'].id,
"rounding_method": "HALF-UP",
})
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()
with freeze_time('2023-12-25'):
cls.invoice_reverse = cls.invoice._reverse_moves()
cls.invoice_reverse.action_post()
cls.invoice_full_discount = cls.init_invoice("out_invoice", post=False, products=cls.product_a)
cls.invoice_full_discount.write({
"invoice_line_ids": [(1, l_id, {"discount": 100}) for l_id in cls.invoice_full_discount.invoice_line_ids.ids]})
cls.invoice_full_discount.action_post()
cls.invoice_zero_qty = cls.init_invoice("out_invoice", post=False, products=cls.product_a)
cls.invoice_zero_qty.write({
"invoice_line_ids": [(1, l_id, {"quantity": 0}) for l_id in cls.invoice_zero_qty.invoice_line_ids.ids]})
cls.invoice_zero_qty.action_post()
cls.invoice_negative_unit_price = cls.init_invoice("out_invoice", post=False, products=cls.product_a + cls.product_a_discount + product_with_cess)
cls.invoice_negative_unit_price.write({
"invoice_line_ids": [
(1, cls.invoice_negative_unit_price.invoice_line_ids[0].id, {"price_unit": 1000}),
(1, cls.invoice_negative_unit_price.invoice_line_ids[1].id, {"price_unit": -400}),
]})
cls.invoice_negative_unit_price.action_post()
cls.invoice_negative_qty = cls.init_invoice("out_invoice", post=False, products=cls.product_a + cls.product_a_discount + product_with_cess)
cls.invoice_negative_qty.write({
"invoice_line_ids": [
(1, cls.invoice_negative_qty.invoice_line_ids[0].id, {"price_unit": 1000}),
(1, cls.invoice_negative_qty.invoice_line_ids[1].id, {"price_unit": 400, 'quantity': -1}),
]})
cls.invoice_negative_qty.action_post()
cls.invoice_negative_unit_price_and_qty = cls.init_invoice("out_invoice", post=False, products=cls.product_a + cls.product_a_discount + product_with_cess)
cls.invoice_negative_unit_price_and_qty.write({
"invoice_line_ids": [
(1, cls.invoice_negative_unit_price_and_qty.invoice_line_ids[0].id, {"price_unit": -1000, 'quantity': -1}),
(1, cls.invoice_negative_unit_price_and_qty.invoice_line_ids[1].id, {"price_unit": -400}),
]})
cls.invoice_negative_unit_price_and_qty.action_post()
cls.invoice_negative_with_discount = cls.init_invoice("out_invoice", post=False, products=cls.product_a + cls.product_a_discount)
cls.invoice_negative_with_discount.write({
"invoice_line_ids": [
(1, cls.invoice_negative_with_discount.invoice_line_ids[0].id, {"price_unit": 2000, 'discount': 50}),
(1, cls.invoice_negative_with_discount.invoice_line_ids[1].id, {"price_unit": -400}),
]})
cls.invoice_negative_with_discount.action_post()
cls.invoice_negative_more_than_max_line = cls.init_invoice("out_invoice", post=False, products=cls.product_a + cls.product_a2 + cls.product_a_discount)
cls.invoice_negative_more_than_max_line.write({
"invoice_line_ids": [
(1, cls.invoice_negative_more_than_max_line.invoice_line_ids[0].id, {"price_unit": 2000, 'discount': 50}),
(1, cls.invoice_negative_more_than_max_line.invoice_line_ids[1].id, {"price_unit": 1000}),
(1, cls.invoice_negative_more_than_max_line.invoice_line_ids[2].id, {"price_unit": -1100}),
]})
cls.invoice_negative_more_than_max_line.action_post()
cls.invoice_cash_rounding = cls.init_invoice("out_invoice", post=False, products=cls.product_a + product_with_cess)
cls.invoice_cash_rounding.write({
"invoice_line_ids": [(1, l_id, {"discount": 10}) for l_id in cls.invoice_cash_rounding.invoice_line_ids.ids],
"invoice_cash_rounding_id": rounding.id,
})
cls.invoice_cash_rounding.action_post()
cls.sez_partner = cls.env['res.partner'].create({
'name': 'SEZ Partner',
'vat': '36AAAAA1234AAZA',
'l10n_in_gst_treatment': 'special_economic_zone',
'street': 'Block no. 402',
'city': 'Some city',
'zip': '500002',
'state_id': cls.env.ref('base.state_in_ts').id,
'country_id': cls.env.ref('base.in').id,
})
cls.invoice_with_intra_igst = cls.init_invoice(
"out_invoice", partner=cls.sez_partner, post=False, products=cls.product_a
)
igst_18 = cls.env.ref(f'l10n_in.{cls.company_data["company"].id}_igst_sale_18')
cls.invoice_with_intra_igst.write({
'invoice_line_ids': [
Command.update(line_id, {'tax_ids': [Command.clear(), Command.set(igst_18.ids)]})
for line_id in cls.invoice_with_intra_igst.invoice_line_ids.ids
]
})
cls.invoice_with_intra_igst.action_post()
cls.overseas = cls.env['res.partner'].create({
'name': 'Overseas',
'vat': False,
'l10n_in_gst_treatment': 'overseas',
'street': 'Block no. 402',
'city': 'Some city',
'zip': '999999',
'country_id': cls.env.ref('base.us').id,
})
cls.invoice_with_export = cls.init_invoice(
"out_invoice", partner=cls.overseas, post=False, products=cls.product_a
)
cls.invoice_with_export.write({
'invoice_line_ids': [
Command.update(line_id, {'tax_ids': [Command.clear(), Command.set(igst_18.ids)]})
for line_id in cls.invoice_with_export.invoice_line_ids.ids
]
})
cls.invoice_with_export.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")
expected_copy_rounding = expected.copy()
# ================================== Credit Note ============================================
credit_note_expected = expected.copy()
credit_note_expected['DocDtls'] = {"Typ": "CRN", "No": "RINV/2023/00001", "Dt": "25/12/2023"}
self.assertDictEqual(
self.env["account.edi.format"]._l10n_in_edi_generate_invoice_json(self.invoice_reverse),
credit_note_expected
)
#=================================== Full discount test =====================================
json_value = self.env["account.edi.format"]._l10n_in_edi_generate_invoice_json(self.invoice_full_discount)
expected.update({
"DocDtls": {"Typ": "INV", "No": "INV/2019/00002", "Dt": "01/01/2019"},
"ItemList": [{
"SlNo": "1", "PrdDesc": "product_a", "IsServc": "N", "HsnCd": "01111", "Qty": 1.0,
"Unit": "UNT", "UnitPrice": 1000.0, "TotAmt": 1000.0, "Discount": 1000.0, "AssAmt": 0.0,
"GstRt": 0.0, "IgstAmt": 0.0, "CgstAmt": 0.0, "SgstAmt": 0.0, "CesRt": 0.0, "CesAmt": 0.0,
"CesNonAdvlAmt": 0.0, "StateCesRt": 0.0, "StateCesAmt": 0.0, "StateCesNonAdvlAmt": 0.0,
"OthChrg": 0.0, "TotItemVal": 0.0}],
"ValDtls": {"AssVal": 0.0, "CgstVal": 0.0, "SgstVal": 0.0, "IgstVal": 0.0, "CesVal": 0.0,
"StCesVal": 0.0, "RndOffAmt": 0.0, "TotInvVal": 0.0}
})
self.assertDictEqual(json_value, expected, "Indian EDI with 100% discount sent json value is not matched")
#=================================== Zero quantity test =============================================
json_value = self.env["account.edi.format"]._l10n_in_edi_generate_invoice_json(self.invoice_zero_qty)
expected.update({
"DocDtls": {"Typ": "INV", "No": "INV/2019/00003", "Dt": "01/01/2019"},
"ItemList": [{
"SlNo": "1", "PrdDesc": "product_a", "IsServc": "N", "HsnCd": "01111", "Qty": 0.0,
"Unit": "UNT", "UnitPrice": 1000.0, "TotAmt": 0.0, "Discount": 0.0, "AssAmt": 0.0,
"GstRt": 0.0, "IgstAmt": 0.0, "CgstAmt": 0.0, "SgstAmt": 0.0, "CesRt": 0.0, "CesAmt": 0.0,
"CesNonAdvlAmt": 0.0, "StateCesRt": 0.0, "StateCesAmt": 0.0, "StateCesNonAdvlAmt": 0.0,
"OthChrg": 0.0, "TotItemVal": 0.0}],
})
self.assertDictEqual(json_value, expected, "Indian EDI with 0(zero) quantity sent json value is not matched")
#=================================== Negative unit price test =============================================
json_value = self.env["account.edi.format"]._l10n_in_edi_generate_invoice_json(self.invoice_negative_unit_price)
expected.update({
"DocDtls": {"Typ": "INV", "No": "INV/2019/00004", "Dt": "01/01/2019"},
"ItemList": [
{
"SlNo": "1", "PrdDesc": "product_a", "IsServc": "N", "HsnCd": "01111", "Qty": 1.0,
"Unit": "UNT", "UnitPrice": 1000.0, "TotAmt": 1000.0, "Discount": 400.0, "AssAmt": 600.0,
"GstRt": 5.0, "IgstAmt": 0.0, "CgstAmt": 15.0, "SgstAmt": 15.0, "CesRt": 0.0, "CesAmt": 0.0,
"CesNonAdvlAmt": 0.0, "StateCesRt": 0.0, "StateCesAmt": 0.0, "StateCesNonAdvlAmt": 0.0,
"OthChrg": 0.0, "TotItemVal": 630.0
},
{
"SlNo": "3", "PrdDesc": "product_with_cess", "IsServc": "N", "HsnCd": "02222", "Qty": 1.0,
"Unit": "UNT", "UnitPrice": 1000.0, "TotAmt": 1000.0, "Discount": 0.0, "AssAmt": 1000.0,
"GstRt": 12.0, "IgstAmt": 0.0, "CgstAmt": 60.0, "SgstAmt": 60.0, "CesRt": 5.0, "CesAmt": 50.0,
"CesNonAdvlAmt": 1.59, "StateCesRt": 0.0, "StateCesAmt": 0.0, "StateCesNonAdvlAmt": 0.0,
"OthChrg": 0.0, "TotItemVal": 1171.59
}
],
"ValDtls": {
"AssVal": 1600.0, "CgstVal": 75.0, "SgstVal": 75.0, "IgstVal": 0.0, "CesVal": 51.59,
"StCesVal": 0.0, "RndOffAmt": 0.0, "TotInvVal": 1801.59
},
})
self.assertDictEqual(json_value, expected, "Indian EDI with negative unit price sent json value is not matched")
expected.update({"DocDtls": {"Typ": "INV", "No": "INV/2019/00005", "Dt": "01/01/2019"}})
json_value = self.env["account.edi.format"]._l10n_in_edi_generate_invoice_json(self.invoice_negative_qty)
self.assertDictEqual(json_value, expected, "Indian EDI with negative quantity sent json value is not matched")
expected.update({"DocDtls": {"Typ": "INV", "No": "INV/2019/00006", "Dt": "01/01/2019"}})
json_value = self.env["account.edi.format"]._l10n_in_edi_generate_invoice_json(self.invoice_negative_unit_price_and_qty)
self.assertDictEqual(json_value, expected, "Indian EDI with negative unit price and quantity sent json value is not matched")
expected.update({
"DocDtls": {"Typ": "INV", "No": "INV/2019/00007", "Dt": "01/01/2019"},
"ItemList": [{
"SlNo": "1", "PrdDesc": "product_a", "IsServc": "N", "HsnCd": "01111", "Qty": 1.0,
"Unit": "UNT", "UnitPrice": 2000.0, "TotAmt": 2000.0, "Discount": 1400.0, "AssAmt": 600.0,
"GstRt": 5.0, "IgstAmt": 0.0, "CgstAmt": 15.0, "SgstAmt": 15.0, "CesRt": 0.0, "CesAmt": 0.0,
"CesNonAdvlAmt": 0.0, "StateCesRt": 0.0, "StateCesAmt": 0.0, "StateCesNonAdvlAmt": 0.0,
"OthChrg": 0.0, "TotItemVal": 630.0
}],
"ValDtls": {
"AssVal": 600.0, "CgstVal": 15.0, "SgstVal": 15.0, "IgstVal": 0.0, "CesVal": 0.0,
"StCesVal": 0.0, "RndOffAmt": 0.0, "TotInvVal": 630.0
},
})
json_value = self.env["account.edi.format"]._l10n_in_edi_generate_invoice_json(self.invoice_negative_with_discount)
self.assertDictEqual(json_value, expected, "Indian EDI with negative unit price and quantity sent json value is not matched")
expected.update({
"DocDtls": {"Typ": "INV", "No": "INV/2019/00008", "Dt": "01/01/2019"},
"ItemList": [{
"SlNo": "1", "PrdDesc": "product_a", "IsServc": "N", "HsnCd": "01111", "Qty": 1.0,
"Unit": "UNT", "UnitPrice": 2000.0, "TotAmt": 2000.0, "Discount": 2000.0, "AssAmt": 0.0,
"GstRt": 5.0, "IgstAmt": 0.0, "CgstAmt": 0.0, "SgstAmt": 0.0, "CesRt": 0.0, "CesAmt": 0.0,
"CesNonAdvlAmt": 0.0, "StateCesRt": 0.0, "StateCesAmt": 0.0, "StateCesNonAdvlAmt": 0.0,
"OthChrg": 0.0, "TotItemVal": 0.0
},
{
"SlNo": "2", "PrdDesc": "product_a2", "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
}],
"ValDtls": {
"AssVal": 900.0, "CgstVal": 22.5, "SgstVal": 22.5, "IgstVal": 0.0, "CesVal": 0.0,
"StCesVal": 0.0, "RndOffAmt": 0.0, "TotInvVal": 945.0
},
})
json_value = self.env['account.edi.format']._l10n_in_edi_generate_invoice_json(self.invoice_negative_more_than_max_line)
self.assertDictEqual(json_value, expected, "Indian EDI with negative value more than max line sent json value is not matched")
json_value = self.env["account.edi.format"]._l10n_in_edi_generate_invoice_json(self.invoice_cash_rounding)
expected_copy_rounding.update({
"DocDtls": {"Typ": "INV", "No": "INV/2019/00009", "Dt": "01/01/2019"},
"ValDtls": {
"AssVal": 1800.0, "CgstVal": 76.5, "SgstVal": 76.5, "IgstVal": 0.0, "CesVal": 46.59,
"StCesVal": 0.0, "RndOffAmt": 0.41, "TotInvVal": 2000.00
}})
self.assertDictEqual(json_value, expected_copy_rounding, "Indian EDI with cash rounding sent json value is not matched")
json_value = self.env["account.edi.format"]._l10n_in_edi_generate_invoice_json(self.invoice_with_intra_igst)
expected_with_intra_igst = {
'Version': '1.1',
'TranDtls': {'TaxSch': 'GST', 'SupTyp': 'SEZWP', 'RegRev': 'N', 'IgstOnIntra': 'Y'},
'DocDtls': {'Typ': 'INV', 'No': 'INV/2019/00010', 'Dt': '01/01/2019'},
'SellerDtls': expected['SellerDtls'],
'BuyerDtls': {
'Addr1': 'Block no. 402',
'Loc': 'Some city',
'Pin': 500002,
'Stcd': '36',
'POS': '36',
'LglNm': 'SEZ Partner',
'GSTIN': '36AAAAA1234AAZA'
},
'ItemList': [{
'SlNo': '1',
'PrdDesc': 'product_a',
'IsServc': 'N',
'HsnCd': '01111',
'Qty': 1.0,
'Unit': 'UNT',
'UnitPrice': 1000.0,
'TotAmt': 1000.0,
'Discount': 0.0,
'AssAmt': 1000.0,
'GstRt': 18.0,
'IgstAmt': 180.0,
'CgstAmt': 0.0,
'SgstAmt': 0.0,
'CesRt': 0.0,
'CesAmt': 0.0,
'CesNonAdvlAmt': 0.0,
'StateCesRt': 0.0,
'StateCesAmt': 0.0,
'StateCesNonAdvlAmt': 0.0,
'OthChrg': 0.0,
'TotItemVal': 1180.0
}],
'ValDtls': {
'AssVal': 1000.0,
'CgstVal': 0.0,
'SgstVal': 0.0,
'IgstVal': 180.0,
'CesVal': 0.0,
'StCesVal': 0.0,
'RndOffAmt': 0.0,
'TotInvVal': 1180.0
}
}
self.assertDictEqual(
json_value,
expected_with_intra_igst,
"Indian EDI with Intra IGST sent json value is not matched"
)
json_value = self.env["account.edi.format"]._l10n_in_edi_generate_invoice_json(self.invoice_with_export)
expected_with_overseas = expected_with_intra_igst.copy()
expected_with_overseas.update({
'TranDtls': {'TaxSch': 'GST', 'SupTyp': 'EXPWP', 'RegRev': 'N', 'IgstOnIntra': 'N'},
'BuyerDtls': {
'Addr1': 'Block no. 402',
'Loc': 'Some city',
'Pin': 999999,
'Stcd': '96',
'POS': '96',
'LglNm': 'Overseas',
'GSTIN': 'URP'
},
'DocDtls': {'Dt': '01/01/2019', 'No': 'INV/2019/00011', 'Typ': 'INV'},
'ExpDtls': {'CntCode': 'US', 'ForCur': 'INR', 'RefClm': 'Y'}
})
self.assertDictEqual(
json_value,
expected_with_overseas,
"Indian EDI with Overseas sent json value is not matched"
)

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="invoice_form_inherit_l10n_in_edi" model="ir.ui.view">
<field name="name">account.move.form.inherit.l10n.in.edi</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='sale_info_group']" position="inside">
<field name="l10n_in_edi_show_cancel" invisible="1"/>
<field name="l10n_in_edi_cancel_reason" attrs="{'invisible': ['|', '|', ('country_code', '!=', 'IN'), ('state', '!=', 'posted'), ('l10n_in_edi_show_cancel', '!=', True)]}"/>
<field name="l10n_in_edi_cancel_remarks" attrs="{'invisible': ['|', '|', ('country_code', '!=', 'IN'), ('state', '!=', 'posted'), ('l10n_in_edi_show_cancel', '!=', True)]}"/>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="l10n_in_einvoice_report_invoice_document_inherit" inherit_id="account.report_invoice_document">
<xpath expr="//div[@id='informations']" position="inside">
<t t-set="l10n_in_einvoice_json" t-value="o._get_l10n_in_edi_response_json()"/>
<div class="col-auto col-3 mw-100 mb-2" t-if="l10n_in_einvoice_json" name="ack_no">
<strong>Acknowledgement:</strong>
<p class="m-0" t-out="l10n_in_einvoice_json['AckNo']"/>
<p t-out="l10n_in_einvoice_json['AckDt']"/>
</div>
</xpath>
<xpath expr="//div[@id='total']/div[1]" position="attributes">
<attribute name="t-attf-class">{{('col-6' if report_type != 'html' else 'col-sm-6 col-md-5 ms-auto') if l10n_in_einvoice_json else ''}}
{{(('col-6' if report_type != 'html' else 'col-sm-7 col-md-6') + ' ms-auto') if not l10n_in_einvoice_json else ''}}</attribute>
</xpath>
<xpath expr="//div[@id='total']/div[1]" position="before">
<t t-set="l10n_in_einvoice_json" t-value="o._get_l10n_in_edi_response_json()"/>
<div t-attf-class="#{'col-6' if report_type != 'html' else 'col-sm-7 col-md-6 ms-auto'} row" t-if="l10n_in_einvoice_json">
<div class="col-7 me-2">
<strong>IRN:</strong>
<span t-esc="l10n_in_einvoice_json['Irn']"/>
</div>
<div class="col-3 mt-1">
<img t-att-src="'/report/barcode/?barcode_type=%s&amp;value=%s&amp;width=%s&amp;height=%s' %('QR', l10n_in_einvoice_json['SignedQRCode'], 500, 500)" style="max-height: 155px"/>
</div>
</div>
</xpath>
</template>
</odoo>

View file

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_inherit_l10n_in_edi" model="ir.ui.view">
<field name="name">res.config.settings.form.inherit.l10n_in_edi</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
<field name="arch" type="xml">
<div data-key="account" position="inside">
<h2 attrs="{'invisible': [('country_code', '!=', 'IN')]}">Indian Electronic Invoicing</h2>
<div class='row mt16 o_settings_container' name="l10n_in_edi_iap" attrs="{'invisible': [('country_code', '!=', 'IN')]}">
<div class="col-12 col-lg-6 o_setting_box" id="gsp_setting">
<div class="o_setting_right_pane">
<span class="o_form_label">Setup E-invoice</span>
<span class="fa fa-lg fa-building-o" title="Values set here are company-specific." aria-label="Values set here are company-specific." groups="base.group_multi_company" role="img"/>
<div class="text-muted">
Check the <a href="https://www.odoo.com/documentation/16.0/applications/finance/fiscal_localizations/india.html">documentation</a> to get credentials
</div>
<div class="content-group">
<div class="mt16 row">
<label for="l10n_in_edi_username" string="Username" class="col-3 col-lg-3 o_light_label"/>
<field name="l10n_in_edi_username" nolabel="1"/>
</div>
<div class="row">
<label for="l10n_in_edi_password" string="Password" class="col-3 col-lg-3 o_light_label" />
<field name="l10n_in_edi_password" password="True" nolabel="1"/>
</div>
<div class="row">
<label for="l10n_in_edi_production_env" string="Production Environment" class="col-3 col-lg-3 o_light_label"/>
<field name="l10n_in_edi_production_env" nolabel="1"/>
</div>
</div>
<div class='mt8'>
<button name="l10n_in_edi_test" icon="fa-arrow-right" type="object" string="Verify Username and Password" class="btn-link"/>
</div>
<div class='mt8'>
<button name="l10n_in_edi_buy_iap" title="Costs 1 credit per transaction. Free 200 credits will be available for the first time." icon="fa-arrow-right" type="object" string="Buy credits" class="btn-link"/>
</div>
</div>
</div>
</div>
</div>
</field>
</record>
</odoo>

View file

@ -0,0 +1,44 @@
[project]
name = "odoo-bringout-oca-ocb-l10n_in_edi"
version = "16.0.0"
description = "Indian - E-invoicing - Odoo addon"
authors = [
{ name = "Ernad Husremovic", email = "hernad@bring.out.ba" }
]
dependencies = [
"odoo-bringout-oca-ocb-account_edi>=16.0.0",
"odoo-bringout-oca-ocb-l10n_in>=16.0.0",
"odoo-bringout-oca-ocb-iap>=16.0.0",
"requests>=2.25.1"
]
readme = "README.md"
requires-python = ">= 3.11"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Office/Business",
]
[project.urls]
homepage = "https://github.com/bringout/0"
repository = "https://github.com/bringout/0"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["l10n_in_edi"]
[tool.rye]
managed = true
dev-dependencies = [
"pytest>=8.4.1",
]