| 摘要 | -科目 | -借方 | -贷方 | +Balance | +Account | +Debit | +Credit | - 合计: + Total: |
@@ -96,13 +94,13 @@
- 审核:
+ Validator:
- 过账:
+ Poster:
- 制单:
+ Salesperson:
').join(footer_fields) + return super()._default_report_footer() + + @api.model + def _default_company_details(self): + # OVERRIDE web/models/base_document_layout + default_company_details = super()._default_company_details() + if self.env.company.external_report_layout_id == self.env.ref('l10n_din5008.external_layout_din5008'): + # In order to respect the strict formatting of DIN5008, we need to remove empty lines from the address + return re.sub(r'(( )* ( )*\n)+', r' \n', default_company_details) + return default_company_details + + report_footer = fields.Html(default=_default_report_footer) + company_details = fields.Html(default=_default_company_details) street = fields.Char(related='company_id.street', readonly=True) street2 = fields.Char(related='company_id.street2', readonly=True) zip = fields.Char(related='company_id.zip', readonly=True) @@ -12,16 +36,6 @@ class BaseDocumentLayout(models.TransientModel): company_registry = fields.Char(related='company_id.company_registry', readonly=True) bank_ids = fields.One2many(related='company_id.partner_id.bank_ids', readonly=True) account_fiscal_country_id = fields.Many2one(related='company_id.account_fiscal_country_id', readonly=True) - l10n_din5008_template_data = fields.Binary(compute='_compute_l10n_din5008_template_data') - l10n_din5008_document_title = fields.Char(compute='_compute_l10n_din5008_document_title') - - def _compute_l10n_din5008_template_data(self): - self.l10n_din5008_template_data = [ - (_("Invoice No."), 'INV/2021/12345'), - (_("Invoice Date"), format_date(self.env, fields.Date.today())), - (_("Due Date"), format_date(self.env, fields.Date.add(fields.Date.today(), days=7))), - (_("Reference"), 'SO/2021/45678'), - ] - - def _compute_l10n_din5008_document_title(self): - self.l10n_din5008_document_title = _('Invoice') + l10n_din5008_invoice_date = fields.Date(default=fields.Date.today, store=False) + l10n_din5008_due_date = fields.Date(default=fields.Date.today() + relativedelta(day=7), store=False) + l10n_din5008_delivery_date = fields.Date(default=fields.Date.today() + relativedelta(day=7), store=False) diff --git a/odoo-bringout-oca-ocb-l10n_din5008/l10n_din5008/models/hr_timesheet.py b/odoo-bringout-oca-ocb-l10n_din5008/l10n_din5008/models/hr_timesheet.py deleted file mode 100644 index afce2f4..0000000 --- a/odoo-bringout-oca-ocb-l10n_din5008/l10n_din5008/models/hr_timesheet.py +++ /dev/null @@ -1,15 +0,0 @@ -from odoo import models, fields - -class AccountAnalyticLine(models.Model): - _inherit = 'account.analytic.line' - - l10n_din5008_template_data = fields.Binary(compute='_compute_l10n_din5008_template_data') - l10n_din5008_document_title = fields.Char(compute='_compute_l10n_din5008_document_title') - - def _compute_l10n_din5008_template_data(self): - for record in self: - record.l10n_din5008_template_data = [] - - def _compute_l10n_din5008_document_title(self): - for record in self: - record.l10n_din5008_document_title = '' diff --git a/odoo-bringout-oca-ocb-l10n_din5008/l10n_din5008/models/res_company.py b/odoo-bringout-oca-ocb-l10n_din5008/l10n_din5008/models/res_company.py new file mode 100644 index 0000000..d065b89 --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_din5008/l10n_din5008/models/res_company.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + has_position_column = fields.Boolean(string="Show Position Column in Reports") diff --git a/odoo-bringout-oca-ocb-l10n_din5008/l10n_din5008/models/res_config_settings.py b/odoo-bringout-oca-ocb-l10n_din5008/l10n_din5008/models/res_config_settings.py new file mode 100644 index 0000000..fbe18cf --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_din5008/l10n_din5008/models/res_config_settings.py @@ -0,0 +1,10 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + has_position_column = fields.Boolean( + related='company_id.has_position_column', + readonly=False, + ) diff --git a/odoo-bringout-oca-ocb-l10n_din5008/l10n_din5008/report/din5008_account_move_layout.xml b/odoo-bringout-oca-ocb-l10n_din5008/l10n_din5008/report/din5008_account_move_layout.xml new file mode 100644 index 0000000..e84fee3 --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_din5008/l10n_din5008/report/din5008_account_move_layout.xml @@ -0,0 +1,14 @@ + +
+
+
-
+
-
-
Page: of
@@ -127,36 +116,31 @@
-
-
-
+
+
+
-
+
-
-
+ 30.00 due if paid before
+ 2024-01-01
+
+
+ 1 - دفعة من
+
+
+ 1 - Installment of
+
+ +
+ المبلغ الإجمالي كتابةً: +
+ INV/2023/00001 :التواصل بشأن الدفع
+
+ Payment Communication: INV/2023/00001
+ -
-
-
+
+
- Payment Reference:
-
-
-
-
-
-
-
- :رقم إشارة الدفعة
-
-
+ Exchange Rate
+ / سعر الصرف
+ / Exchange Rate
+ |
+
+ | -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
- -
-
-
-
-
- Incoterm:
-
-
- -
-
-
-
- شرط تجاري:
-
-
- -
-
-
- + +
+
+
+
+ Served by / خدم بواسطة
+ - -
- Tax Invoice
-
-
- الفاتورة الضريبية
-
-
-
- Served by / خدم بواسطة
-
- Subtotal / الإجمالي الفرعي
-
-
-
- TOTAL / الإجمالي
-
-
-
- Rounding / التقريب
-
-
-
- To Pay / للسداد
-
-
-
- CHANGE / الباقي
-
-
-
- Discounts / الخصومات
-
-
-
- Total Taxes / إجمالي الضرائب
-
-
-
-
-
-
-
- Taxes / الضرائب :
-
-
-
-
-
-
-
- Taxes / الضرائب :
-
-
- Discount / الخصم :
+ This invoice was paid by Zainal Arief using QRIS with the payment method Sakuku. ') + ) + # One of the QRIS transactions linked to the invoice should be paid already + self.assertTrue(any(self.qris_qr_invoice.l10n_id_qris_transaction_ids.mapped('paid'))) + + @freeze_time("2024-02-27 04:15:00") + def test_gc_no_remove_transactions_unpaid_within_30(self): + # Case 2: Unsuccessful, latest transaction is < 30 minutes, should avoid garbage-collection + with patch( + 'odoo.addons.l10n_id.models.res_bank._l10n_id_make_qris_request', return_value=self.success_qris_get + ): + self.qris_qr_invoice.with_context({'is_online_qr': True})._generate_qr_code() + + with patch( + 'odoo.addons.l10n_id.models.res_bank._l10n_id_make_qris_request', return_value=self.qris_status_fail + ): + self.qris_qr_invoice.action_l10n_id_update_payment_status() + self.assertEqual(self.env['l10n_id.qris.transaction'].search_count([]), 1) + self.env['l10n_id.qris.transaction']._gc_remove_pointless_qris_transactions() + self.assertEqual(self.env['l10n_id.qris.transaction'].search_count([]), 1) + + @freeze_time("2024-02-27 05:15:00") + def test_gc_remove_transactions_unpaid_after_30(self): + # Case 3: Unsuccessful, latest transaction is > 30 minutes, should be garbage-collected + with patch( + 'odoo.addons.l10n_id.models.res_bank._l10n_id_make_qris_request', return_value=self.success_qris_get + ): + self.qris_qr_invoice.with_context({'is_online_qr': True})._generate_qr_code() + self.assertEqual(self.env['l10n_id.qris.transaction'].search_count([]), 1) + self.env['l10n_id.qris.transaction']._gc_remove_pointless_qris_transactions() + self.assertEqual(self.env['l10n_id.qris.transaction'].search_count([]), 0) diff --git a/odoo-bringout-oca-ocb-l10n_id/l10n_id/tests/test_qris_transaction.py b/odoo-bringout-oca-ocb-l10n_id/l10n_id/tests/test_qris_transaction.py new file mode 100644 index 0000000..4242e58 --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_id/l10n_id/tests/test_qris_transaction.py @@ -0,0 +1,111 @@ + +from odoo.fields import Command +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged +from unittest.mock import patch + + +@tagged('post_install_l10n', 'post_install', '-at_install') +class TestQrisTransaction(AccountTestInvoicingCommon): + """ Testing the behaviours of QRIS Transaction """ + + @classmethod + @AccountTestInvoicingCommon.setup_chart_template('id') + def setUpClass(cls): + super().setUpClass() + cls.invoice = cls.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': cls.partner_a.id, + 'invoice_date': '2019-05-01', + 'date': '2019-05-01', + 'invoice_line_ids': [ + Command.create({'name': 'line1', 'price_unit': 110.0}), + ], + }) + cls.qris_status_success = { + "status": "success", + "data": { + "qris_status": "paid", + "qris_payment_customername": "Zainal Arief", + "qris_payment_methodby": "Sakuku" + }, + "qris_api_version_code": "2206091709" + } + cls.qris_status_fail = { + "status": "failed", + "data": { + "qris_status": "unpaid" + } + } + + cls.acc_qris_id = cls.env['res.partner.bank'].create({ + 'acc_number': '123456789012345678', + 'partner_id': cls.company_data['company'].partner_id.id, + 'l10n_id_qris_api_key': 'apikey', + 'l10n_id_qris_mid': 'mid', + }) + + # Utility method to help create QRIS transactions + def _create_sample_transaction(self, model, model_id, qris_id, amount, create_at, content): + return self.env['l10n_id.qris.transaction'].create({ + 'model': model, + 'model_id': model_id, + 'qris_invoice_id': qris_id, + 'qris_amount': amount, + 'qris_creation_datetime': create_at, + 'qris_content': content, + }) + + def test_retrieve_backend_record(self): + """ Test the _get_record method to retrieve original record accordingly """ + trx = self._create_sample_transaction( + "account.move", str(self.invoice.id), "11254", 11000, "2024-08-01", "qris_content_sample" + ) + invoice = trx._get_record() + self.assertEqual(invoice, self.invoice) + + def test_latest_transaction(self): + """ Test method _get_latest_transaction""" + self._create_sample_transaction( + "account.move", "1", "11254", 11000, "2024-08-01 03:00:00", "qris_content_sample" + ) + self._create_sample_transaction( + "account.move", "1", "11254", 11000, "2024-08-01 03:00:15", "qris_content_sample_latest" + ) + + trx = self.env['l10n_id.qris.transaction']._get_latest_transaction('account.move', '1') + self.assertTrue( + trx['qris_amount'] == 11000 and trx['qris_content'] == "qris_content_sample_latest" + ) + + def test_l10n_id_get_qris_qr_statuses(self): + """ Test the method _l10n_id_get_qris_qr_statuses """ + + # Create QRIS transaction with 2 entries in invoice details + trx = self._create_sample_transaction( + "account.move", "1", "11253", 11000, "2024-08-01", "qris_content_sample" + ) + trx |= self._create_sample_transaction( + "account.move", "1", "11254", 11000, "2024-08-01", "qris_content_sample" + ) + + # if QRIS returns success, _l10n_id_make_request should only be called once and + # status returned is {'paid': True, 'qr_statuses': [{self.qris_status_success}]} + # and check that the transaction is also paid + with patch( + 'odoo.addons.l10n_id.models.res_bank._l10n_id_make_qris_request', return_value=self.qris_status_success + ) as patched: + res = trx._l10n_id_get_qris_qr_statuses() + patched.assert_called_once() + self.assertEqual(len(res['qr_statuses']), 1) + success_response = res['qr_statuses'][0] + self.assertTrue(res['paid'] and success_response['qris_payment_customername'] == 'Zainal Arief' and trx[0].paid) + + # if QRIS returns fail for all, _l10n_id_make-request should be called twice and + # status returned is {'paid': False, 'qr_statuses': [{self.qris_status}, {self.qris_status_fail}]} + with patch( + 'odoo.addons.l10n_id.models.res_bank._l10n_id_make_qris_request', return_value=self.qris_status_fail + ) as patched: + res = trx._l10n_id_get_qris_qr_statuses() + self.assertEqual(patched.call_count, 2) + self.assertEqual(len(res['qr_statuses']), 2) diff --git a/odoo-bringout-oca-ocb-l10n_id/l10n_id/views/account_move_views.xml b/odoo-bringout-oca-ocb-l10n_id/l10n_id/views/account_move_views.xml new file mode 100644 index 0000000..855d524 --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_id/l10n_id/views/account_move_views.xml @@ -0,0 +1,15 @@ + +%s %s %s """) % ( + _("You have insufficient credits to send this document!"), + _("Please buy more credits and retry: "), + url, + _("Buy Credits") + ) + + def _get_sync_stack(self, container): + stack, update_containers = super()._get_sync_stack(container) + if all(move.country_code != 'IN' for move in self): + return stack, update_containers + _tax_container, invoice_container, misc_container = update_containers() + moves = invoice_container['records'] + misc_container['records'] + stack.append((9, self._sync_l10n_in_gstr_section(moves))) + return stack, update_containers + + @contextmanager + def _sync_l10n_in_gstr_section(self, moves): + yield + tax_tags_dict = self.env['account.move.line']._get_l10n_in_tax_tag_ids() + # we set the section on the invoice lines + moves.line_ids._set_l10n_in_gstr_section(tax_tags_dict) + + def _get_l10n_in_invoice_label(self): + self.ensure_one() + exempt_types = {'exempt', 'nil_rated', 'non_gst'} + if self.country_code != 'IN' or not self.is_sale_document(include_receipts=False): + return + gst_treatment = self.l10n_in_gst_treatment + company = self.company_id + tax_types = set(self.invoice_line_ids.tax_ids.mapped('l10n_in_tax_type')) + if company.l10n_in_is_gst_registered and tax_types: + if gst_treatment in ['overseas', 'special_economic_zone']: + return 'Tax Invoice' + elif tax_types.issubset(exempt_types): + return 'Bill of Supply' + elif tax_types.isdisjoint(exempt_types): + return 'Tax Invoice' + elif gst_treatment in ['unregistered', 'consumer']: + return 'Invoice-cum-Bill of Supply' + return 'Invoice' diff --git a/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/account_journal.py b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/account_journal.py new file mode 100644 index 0000000..a95f1a2 --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/account_journal.py @@ -0,0 +1,30 @@ +from odoo import models + + +class AccountJournal(models.Model): + _inherit = "account.journal" + + def _update_payment_method_lines(self, payment_type): + bank_journals = self.filtered(lambda j: j.type == "bank" and j.company_id.chart_template == "in") + if not bank_journals: + return + + if payment_type == 'inbound': + account_xmlid = "account_journal_payment_debit_account_id" + else: + account_xmlid = "account_journal_payment_credit_account_id" + + lines_to_update = bank_journals[f"{payment_type}_payment_method_line_ids"].filtered( + lambda l: l.payment_method_id.code == 'manual' + ) + for company, lines in lines_to_update.grouped('company_id').items(): + if account := self.env['account.chart.template'].with_company(company).ref(account_xmlid, raise_if_not_found=False): + lines.payment_account_id = account + + def _compute_inbound_payment_method_line_ids(self): + super()._compute_inbound_payment_method_line_ids() + self._update_payment_method_lines("inbound") + + def _compute_outbound_payment_method_line_ids(self): + super()._compute_outbound_payment_method_line_ids() + self._update_payment_method_lines("outbound") diff --git a/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/account_move_line.py b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/account_move_line.py new file mode 100644 index 0000000..cc80081 --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/account_move_line.py @@ -0,0 +1,318 @@ +import re +from datetime import date + +from odoo import _, api, fields, models + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + l10n_in_hsn_code = fields.Char(string="HSN/SAC Code", compute="_compute_l10n_in_hsn_code", store=True, readonly=False, copy=False) + l10n_in_gstr_section = fields.Selection( + selection=[ + ("sale_b2b_rcm", "B2B RCM"), + ("sale_b2b_regular", "B2B Regular"), + ("sale_b2cl", "B2CL"), + ("sale_b2cs", "B2CS"), + ("sale_exp_wp", "EXP(WP)"), + ("sale_exp_wop", "EXP(WOP)"), + ("sale_sez_wp", "SEZ(WP)"), + ("sale_sez_wop", "SEZ(WOP)"), + ("sale_deemed_export", "Deemed Export"), + ("sale_cdnr_rcm", "CDNR RCM"), + ("sale_cdnr_regular", "CDNR Regular"), + ("sale_cdnr_deemed_export", "CDNR(Deemed Export)"), + ("sale_cdnr_sez_wp", "CDNR(SEZ-WP)"), + ("sale_cdnr_sez_wop", "CDNR(SEZ-WOP)"), + ("sale_cdnur_b2cl", "CDNUR(B2CL)"), + ("sale_cdnur_exp_wp", "CDNUR(EXP-WP)"), + ("sale_cdnur_exp_wop", "CDNUR(EXP-WOP)"), + ("sale_nil_rated", "Nil Rated"), + ("sale_exempt", "Exempt"), + ("sale_non_gst_supplies", "Non-GST Supplies"), + ("sale_eco_9_5", "ECO 9(5)"), + ("sale_out_of_scope", "Out of Scope"), + ("purchase_b2b_regular", "B2B Regular"), + ("purchase_b2c_regular", "B2C Regular"), # will be removed in master + ("purchase_b2b_rcm", "B2B RCM"), + ("purchase_b2c_rcm", "B2C RCM"), + ("purchase_imp_services", "IMP(service)"), + ("purchase_imp_goods", "IMP(goods)"), + ("purchase_cdnr_regular", "CDNR Regular"), + ("purchase_cdnur_regular", "CDNUR Regular"), + ("purchase_cdnr_rcm", "CDNR RCM"), + ("purchase_cdnur_rcm", "CDNUR RCM"), + ("purchase_nil_rated", "Nil Rated"), + ("purchase_exempt", "Exempt"), + ("purchase_non_gst_supplies", "Non-GST Supplies"), + ("purchase_out_of_scope", "Out of Scope"), + ], + string="GSTR Section", + index="btree_not_null", + ) + + # withholding related fields + l10n_in_withhold_tax_amount = fields.Monetary(string="TDS Tax Amount", compute='_compute_l10n_in_withhold_tax_amount') + l10n_in_tds_tcs_section_id = fields.Many2one(related="account_id.l10n_in_tds_tcs_section_id") + + @api.depends('tax_ids') + def _compute_l10n_in_withhold_tax_amount(self): + # Compute the withhold tax amount for the withholding lines + withholding_lines = self.filtered('move_id.l10n_in_is_withholding') + (self - withholding_lines).l10n_in_withhold_tax_amount = False + for line in withholding_lines: + line.l10n_in_withhold_tax_amount = line.currency_id.round(abs(line.price_total - line.price_subtotal)) + + @api.depends('product_id', 'product_id.l10n_in_hsn_code') + def _compute_l10n_in_hsn_code(self): + for line in self: + if line.move_id.country_code == 'IN' and line.parent_state == 'draft': + line.l10n_in_hsn_code = line.product_id.l10n_in_hsn_code + + def _l10n_in_check_invalid_hsn_code(self): + self.ensure_one() + hsn_code = self.env['account.move']._l10n_in_extract_digits(self.l10n_in_hsn_code) + if not hsn_code: + return _("HSN code is not set in product line %(name)s", name=self.name) + elif not re.match(r'^\d{4}$|^\d{6}$|^\d{8}$', hsn_code): + return _( + "Invalid HSN Code (%(hsn_code)s) in product line %(product_line)s", + hsn_code=hsn_code, + product_line=self.product_id.name or self.name + ) + return False + + def _get_l10n_in_tax_tag_ids(self): + xmlid_to_res_id = self.env['ir.model.data']._xmlid_to_res_id + tag_refs = { + 'sgst': ['l10n_in.tax_tag_base_sgst', 'l10n_in.tax_tag_sgst'], + 'cgst': ['l10n_in.tax_tag_base_cgst', 'l10n_in.tax_tag_cgst'], + 'igst': ['l10n_in.tax_tag_base_igst', 'l10n_in.tax_tag_igst'], + 'cess': ['l10n_in.tax_tag_base_cess', 'l10n_in.tax_tag_cess'], + 'eco_9_5': ['l10n_in.tax_tag_eco_9_5'], + } + return { + categ: [xmlid_to_res_id(xml_id) for xml_id in ref] + for categ, ref in tag_refs.items() + } + + def _get_l10n_in_gstr_section(self, tax_tags_dict): + + def tags_have_categ(line_tax_tags, categories): + return any(tag in line_tax_tags for category in categories for tag in tax_tags_dict.get(category, [])) + + def is_invoice(move): + return move.is_inbound() and not move.debit_origin_id + + def is_move_bill(move): + return move.is_outbound() and not move.debit_origin_id + + def get_transaction_type(move): + return 'intra_state' if move.l10n_in_state_id == move.company_id.state_id else 'inter_state' + + def is_reverse_charge_tax(line): + return any(tax.l10n_in_reverse_charge for tax in line.tax_ids | line.tax_line_id) + + def is_lut_tax(line): + return any(tax.l10n_in_is_lut for tax in line.tax_ids | line.tax_line_id) + + def get_sales_section(line): + move = line.move_id + gst_treatment = move.l10n_in_gst_treatment + transaction_type = get_transaction_type(move) + line_tags = line.tax_tag_ids.ids + is_inv = is_invoice(move) + amt_limit = 100000 if not line.invoice_date or line.invoice_date >= date(2024, 11, 1) else 250000 + + # ECO 9(5) Section: Check if the line has the ECO 9(5) tax tag + if tags_have_categ(line_tags, ['eco_9_5']): + return 'sale_eco_9_5' + + # Nil rated, Exempt, Non-GST Sales + if gst_treatment != 'overseas': + if any(tax.l10n_in_tax_type == 'nil_rated' for tax in line.tax_ids): + return 'sale_nil_rated' + elif any(tax.l10n_in_tax_type == 'exempt' for tax in line.tax_ids): + return 'sale_exempt' + elif any(tax.l10n_in_tax_type == 'non_gst' for tax in line.tax_ids): + return 'sale_non_gst_supplies' + + # B2CS: Unregistered or Consumer sales with gst tags + if gst_treatment in ('unregistered', 'consumer') and not is_reverse_charge_tax(line): + if (transaction_type == 'intra_state' and tags_have_categ(line_tags, ['sgst', 'cgst', 'cess'])) or ( + transaction_type == "inter_state" + and tags_have_categ(line_tags, ['igst', 'cess']) + and not is_lut_tax(line) + and ( + is_inv and move.amount_total <= amt_limit + or move.debit_origin_id and move.debit_origin_id.amount_total <= amt_limit + or move.reversed_entry_id and move.reversed_entry_id.amount_total <= amt_limit + ) + ): + return 'sale_b2cs' + + # If no relevant tags are found, or the tags do not match any category, mark as out of scope + if not line_tags or not tags_have_categ(line_tags, ['sgst', 'cgst', 'igst', 'cess', 'eco_9_5']): + return 'sale_out_of_scope' + + # If it's a standard invoice (not a debit/credit note) + if is_inv: + # B2B with Reverse Charge and Regular + if gst_treatment in ('regular', 'composition', 'uin_holders') and tags_have_categ(line_tags, ['sgst', 'cgst', 'igst', 'cess']) and not is_lut_tax(line): + if is_reverse_charge_tax(line): + return 'sale_b2b_rcm' + return 'sale_b2b_regular' + + if not is_reverse_charge_tax(line): + # B2CL: Unregistered interstate sales above threshold + if ( + gst_treatment in ('unregistered', 'consumer') + and tags_have_categ(line_tags, ['igst', 'cess']) + and not is_lut_tax(line) + and transaction_type == 'inter_state' + and move.amount_total > amt_limit + ): + return 'sale_b2cl' + # Export with payment and without payment (under LUT) of tax + if gst_treatment == 'overseas' and tags_have_categ(line_tags, ['igst', 'cess']): + if is_lut_tax(line): + return 'sale_exp_wop' + return 'sale_exp_wp' + # SEZ with payment and without payment of tax + if gst_treatment == 'special_economic_zone' and tags_have_categ(line_tags, ['igst', 'cess']): + if is_lut_tax(line): + return 'sale_sez_wop' + return 'sale_sez_wp' + # Deemed export + if gst_treatment == 'deemed_export' and tags_have_categ(line_tags, ['sgst', 'cgst', 'igst', 'cess']) and not is_lut_tax(line): + return 'sale_deemed_export' + + # If it's not a standard invoice (i.e., it's a debit/credit note) + if not is_inv: + # CDN for B2B reverse charge and B2B regular + if gst_treatment in ('regular', 'composition', 'uin_holders') and tags_have_categ(line_tags, ['sgst', 'cgst', 'igst', 'cess']) and not is_lut_tax(line): + if is_reverse_charge_tax(line): + return 'sale_cdnr_rcm' + return 'sale_cdnr_regular' + if not is_reverse_charge_tax(line): + # CDN for SEZ exports with payment and without payment + if gst_treatment == 'special_economic_zone' and tags_have_categ(line_tags, ['igst', 'cess']): + if is_lut_tax(line): + return 'sale_cdnr_sez_wop' + return 'sale_cdnr_sez_wp' + # CDN for deemed exports + if gst_treatment == 'deemed_export' and tags_have_categ(line_tags, ['sgst', 'cgst', 'igst', 'cess']) and not is_lut_tax(line): + return 'sale_cdnr_deemed_export' + # CDN for B2CL (interstate > threshold) + if ( + gst_treatment in ('unregistered', 'consumer') + and tags_have_categ(line_tags, ['igst', 'cess']) + and not is_lut_tax(line) + and transaction_type == 'inter_state' + and ( + move.debit_origin_id and move.debit_origin_id.amount_total > amt_limit + or move.reversed_entry_id and move.reversed_entry_id.amount_total > amt_limit + or not move.reversed_entry_id and not move.is_inbound() + ) + ): + return 'sale_cdnur_b2cl' + # CDN for exports with payment and without payment + if gst_treatment == 'overseas' and tags_have_categ(line_tags, ['igst', 'cess']): + if is_lut_tax(line): + return 'sale_cdnur_exp_wop' + return 'sale_cdnur_exp_wp' + # If none of the above match, default to out of scope + return 'sale_out_of_scope' + + def get_purchase_section(line): + move = line.move_id + gst_treatment = move.l10n_in_gst_treatment + line_tags = line.tax_tag_ids.ids + is_bill = is_move_bill(move) + + # Nil rated, Exempt, Non-GST purchases + if gst_treatment != 'overseas': + if any(tax.l10n_in_tax_type == 'nil_rated' for tax in line.tax_ids): + return 'purchase_nil_rated' + elif any(tax.l10n_in_tax_type == 'exempt' for tax in line.tax_ids): + return 'purchase_exempt' + elif any(tax.l10n_in_tax_type == 'non_gst' for tax in line.tax_ids): + return 'purchase_non_gst_supplies' + + # If no relevant tags are found, or the tags do not match any category, mark as out of scope + if not line_tags or not tags_have_categ(line_tags, ['sgst', 'cgst', 'igst', 'cess']): + return 'purchase_out_of_scope' + + if is_bill: + # B2B Regular and Reverse Charge purchases + if (gst_treatment in ('regular', 'composition', 'uin_holders') and tags_have_categ(line_tags, ['sgst', 'cgst', 'igst', 'cess'])): + if is_reverse_charge_tax(line): + return 'purchase_b2b_rcm' + return 'purchase_b2b_regular' + + if not is_reverse_charge_tax(line) and ( + gst_treatment == 'deemed_export' and tags_have_categ(line_tags, ['sgst', 'cgst', 'igst', 'cess']) + or gst_treatment == 'special_economic_zone' and tags_have_categ(line_tags, ['igst', 'cess']) + ): + return 'purchase_b2b_regular' + + # B2C Unregistered or Consumer sales with gst tags + if gst_treatment in ('unregistered', 'consumer') and tags_have_categ(line_tags, ['sgst', 'cgst', 'igst', 'cess']) and is_reverse_charge_tax(line): + return 'purchase_b2c_rcm' + + # export service type products purchases + if gst_treatment == 'overseas' and any(tax.tax_scope == 'service' for tax in line.tax_ids | line.tax_line_id) and tags_have_categ(line_tags, ['igst', 'cess']): + return 'purchase_imp_services' + + # export goods type products purchases + if gst_treatment == 'overseas' and tags_have_categ(line_tags, ['igst', 'cess']) and not is_reverse_charge_tax(line): + return 'purchase_imp_goods' + + if not is_bill: + # credit notes for b2b purchases + if gst_treatment in ('regular', 'composition', 'uin_holders') and tags_have_categ(line_tags, ['sgst', 'cgst', 'igst', 'cess']): + if is_reverse_charge_tax(line): + return 'purchase_cdnr_rcm' + return 'purchase_cdnr_regular' + + # credit notes for b2c purchases + if gst_treatment in ('unregistered', 'consumer') and tags_have_categ(line_tags, ['sgst', 'cgst', 'igst', 'cess']) and is_reverse_charge_tax(line): + return 'purchase_cdnur_rcm' + + if not is_reverse_charge_tax(line): + if gst_treatment == 'deemed_export' and tags_have_categ(line_tags, ['sgst', 'cgst', 'igst', 'cess'])\ + or gst_treatment == 'special_economic_zone' and tags_have_categ(line_tags, ['igst', 'cess']): + return 'purchase_cdnr_regular' + + if gst_treatment == 'overseas' and tags_have_categ(line_tags, ['igst', 'cess']): + return 'purchase_cdnur_regular' + + # If none of the above match, default to out of scope + return 'purchase_out_of_scope' + + indian_sale_moves_lines = self.filtered( + lambda l: l.move_id.country_code == 'IN' + and l.move_id.is_sale_document(include_receipts=True) + and l.display_type in ('product', 'tax') + ) + indian_moves_purchase_lines = self.filtered( + lambda l: l.move_id.country_code == 'IN' + and l.move_id.is_purchase_document(include_receipts=True) + and l.display_type in ('product', 'tax') + ) + # No Indian sale or purchase lines to process + if not indian_sale_moves_lines and not indian_moves_purchase_lines: + return {} + + move_lines_by_gstr_section = { + **indian_sale_moves_lines.grouped(get_sales_section), + **indian_moves_purchase_lines.grouped(get_purchase_section), + } + + return move_lines_by_gstr_section + + def _set_l10n_in_gstr_section(self, tax_tags_dict): + move_lines_by_gstr_section = self._get_l10n_in_gstr_section(tax_tags_dict) + if move_lines_by_gstr_section: + for gstr_section, move_lines in move_lines_by_gstr_section.items(): + move_lines.l10n_in_gstr_section = gstr_section diff --git a/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/account_payment.py b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/account_payment.py new file mode 100644 index 0000000..2fca3b5 --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/account_payment.py @@ -0,0 +1,31 @@ +from odoo import fields, models + + +class AccountPayment(models.Model): + _inherit = "account.payment" + + # withholding related fields + l10n_in_withhold_move_ids = fields.One2many( + 'account.move', 'l10n_in_withholding_ref_payment_id', + string="Indian Payment TDS Entries", + ) + l10n_in_total_withholding_amount = fields.Monetary(compute='_compute_l10n_in_total_withholding_amount') + l10n_in_tds_feature_enabled = fields.Boolean(related='company_id.l10n_in_tds_feature') + + def _compute_l10n_in_total_withholding_amount(self): + for payment in self: + if payment.company_id.l10n_in_tds_feature: + payment.l10n_in_total_withholding_amount = sum(payment.l10n_in_withhold_move_ids.filtered( + lambda m: m.state == 'posted').l10n_in_withholding_line_ids.mapped('l10n_in_withhold_tax_amount')) + else: + payment.l10n_in_total_withholding_amount = 0.0 + + def action_l10n_in_withholding_entries(self): + self.ensure_one() + return { + 'name': "TDS Entries", + 'type': 'ir.actions.act_window', + 'res_model': 'account.move', + 'view_mode': 'list,form', + 'domain': [('id', 'in', self.l10n_in_withhold_move_ids.ids)], + } diff --git a/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/account_tax.py b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/account_tax.py new file mode 100644 index 0000000..5776699 --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/account_tax.py @@ -0,0 +1,143 @@ +from collections import defaultdict + +from odoo import api, fields, models +from odoo.tools import frozendict + + +class AccountTax(models.Model): + _inherit = 'account.tax' + + l10n_in_reverse_charge = fields.Boolean("Reverse charge", help="Tick this if this tax is reverse charge. Only for Indian accounting") + l10n_in_gst_tax_type = fields.Selection( + selection=[('igst', 'igst'), ('cgst', 'cgst'), ('sgst', 'sgst'), ('cess', 'cess')], + compute='_compute_l10n_in_gst_tax_type', + ) + l10n_in_is_lut = fields.Boolean( + string="LUT", + help="Tick this if this tax is used in LUT (Letter of Undertaking) transactions. Only for Indian accounting.", + ) + l10n_in_tax_type = fields.Selection( + selection=[ + ('gst', 'GST'), + ('tcs', 'TCS'), + ('tds_sale', 'TDS Sale'), + ('tds_purchase', 'TDS Purchase'), + ('nil_rated', 'Nil Rated'), + ('exempt', 'Exempt'), + ('non_gst', 'Non-GST'), + ], + string="Indian Tax Type", + ) + + # withholding related fields + l10n_in_section_id = fields.Many2one('l10n_in.section.alert', string="Section") + l10n_in_tds_feature_enabled = fields.Boolean(related='company_id.l10n_in_tds_feature') + l10n_in_tcs_feature_enabled = fields.Boolean(related='company_id.l10n_in_tcs_feature') + + @api.depends('country_code', 'invoice_repartition_line_ids.tag_ids') + def _compute_l10n_in_gst_tax_type(self): + self.l10n_in_gst_tax_type = False + in_taxes = self.filtered(lambda tax: tax.country_code == 'IN') + if in_taxes: + tags_mapping = { + 'igst': self.env.ref('l10n_in.tax_tag_igst'), + 'cgst': self.env.ref('l10n_in.tax_tag_cgst'), + 'sgst': self.env.ref('l10n_in.tax_tag_sgst'), + 'cess': self.env.ref('l10n_in.tax_tag_cess'), + } + for tax in in_taxes: + tags = tax.invoice_repartition_line_ids.tag_ids + for tag_code, tag in tags_mapping.items(): + if tag in tags: + tax.l10n_in_gst_tax_type = tag_code + break + + # ------------------------------------------------------------------------- + # HELPERS IN BOTH PYTHON/JAVASCRIPT (hsn_summary.js / account_tax.py) + + # HSN SUMMARY + # ------------------------------------------------------------------------- + + def _prepare_base_line_for_taxes_computation(self, record, **kwargs): + # EXTENDS 'account' + results = super()._prepare_base_line_for_taxes_computation(record, **kwargs) + results['l10n_in_hsn_code'] = self._get_base_line_field_value_from_record(record, 'l10n_in_hsn_code', kwargs, False) + return results + + @api.model + def _l10n_in_get_hsn_summary_table(self, base_lines, display_uom): + l10n_in_gst_tax_types = set() + items_map = defaultdict(lambda: { + 'quantity': 0.0, + 'amount_untaxed': 0.0, + 'tax_amount_igst': 0.0, + 'tax_amount_cgst': 0.0, + 'tax_amount_sgst': 0.0, + 'tax_amount_cess': 0.0, + }) + + def get_base_line_grouping_key(base_line): + unique_taxes_data = set( + tax_data['tax'] + for tax_data in base_line['tax_details']['taxes_data'] + if tax_data['tax']['l10n_in_gst_tax_type'] in ('igst', 'cgst', 'sgst') + ) + rate = sum(tax.amount for tax in unique_taxes_data) + + return { + 'l10n_in_hsn_code': base_line['l10n_in_hsn_code'], + 'uom_name': base_line['product_uom_id'].name, + 'rate': rate, + } + + # quantity / amount_untaxed. + for base_line in base_lines: + key = frozendict(get_base_line_grouping_key(base_line)) + if not key['l10n_in_hsn_code']: + continue + + item = items_map[key] + item['quantity'] += base_line['quantity'] + item['amount_untaxed'] += ( + base_line['tax_details']['total_excluded_currency'] + + base_line['tax_details']['delta_total_excluded_currency'] + ) + + # Tax amounts. + def grouping_function(base_line, tax_data): + return { + **get_base_line_grouping_key(base_line), + 'l10n_in_gst_tax_type': tax_data['tax'].l10n_in_gst_tax_type, + } if tax_data else None + + base_lines_aggregated_values = self._aggregate_base_lines_tax_details(base_lines, grouping_function) + values_per_grouping_key = self._aggregate_base_lines_aggregated_values(base_lines_aggregated_values) + for grouping_key, values in values_per_grouping_key.items(): + if ( + not grouping_key + or not grouping_key['l10n_in_hsn_code'] + or not grouping_key['l10n_in_gst_tax_type'] + ): + continue + + key = frozendict({ + 'l10n_in_hsn_code': grouping_key['l10n_in_hsn_code'], + 'rate': grouping_key['rate'], + 'uom_name': grouping_key['uom_name'], + }) + item = items_map[key] + l10n_in_gst_tax_type = grouping_key['l10n_in_gst_tax_type'] + item[f'tax_amount_{l10n_in_gst_tax_type}'] += values['tax_amount_currency'] + l10n_in_gst_tax_types.add(l10n_in_gst_tax_type) + + return { + 'has_igst': 'igst' in l10n_in_gst_tax_types, + 'has_gst': bool({'cgst', 'sgst'} & l10n_in_gst_tax_types), + 'has_cess': 'cess' in l10n_in_gst_tax_types, + 'nb_columns': 5 + len(l10n_in_gst_tax_types), + 'display_uom': display_uom, + 'items': [ + key | values + for key, values in items_map.items() + ], + } diff --git a/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/chart_template.py b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/chart_template.py deleted file mode 100644 index 57d9425..0000000 --- a/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/chart_template.py +++ /dev/null @@ -1,37 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models, _ - - -class AccountChartTemplate(models.Model): - _inherit = 'account.chart.template' - - def _prepare_all_journals(self, acc_template_ref, company, journals_dict=None): - res = super(AccountChartTemplate, self)._prepare_all_journals(acc_template_ref, company, journals_dict=journals_dict) - if self == self.env.ref('l10n_in.indian_chart_template_standard'): - for journal in res: - if journal.get('type') in ('sale','purchase'): - journal['l10n_in_gstin_partner_id'] = company.partner_id.id - return res - - def _load(self, company): - res = super(AccountChartTemplate, self)._load(company) - if self == self.env.ref("l10n_in.indian_chart_template_standard"): - company.write({ - 'account_opening_date': fields.Date.context_today(self).replace(month=4, day=1), - 'fiscalyear_last_month': '3', - }) - return res - - -class AccountTaxTemplate(models.Model): - _inherit = 'account.tax.template' - - l10n_in_reverse_charge = fields.Boolean("Reverse charge", help="Tick this if this tax is reverse charge. Only for Indian accounting") - - def _get_tax_vals(self, company, tax_template_to_tax): - val = super(AccountTaxTemplate, self)._get_tax_vals(company, tax_template_to_tax) - if self.tax_group_id: - val['l10n_in_reverse_charge'] = self.l10n_in_reverse_charge - return val diff --git a/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/company.py b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/company.py new file mode 100644 index 0000000..3a191ea --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/company.py @@ -0,0 +1,195 @@ +import pytz +from stdnum.in_ import pan, gstin + +from odoo import Command, _, api, fields, models +from odoo.exceptions import RedirectWarning + + +class ResCompany(models.Model): + _inherit = 'res.company' + + l10n_in_upi_id = fields.Char(string="UPI Id") + l10n_in_hsn_code_digit = fields.Selection( + selection=[ + ("4", "4 Digits (turnover < 5 CR.)"), + ("6", "6 Digits (turnover > 5 CR.)"), + ("8", "8 Digits"), + ], + string="HSN Code Digit", + compute="_compute_l10n_in_hsn_code_digit", + store=True, + readonly=False, + ) + l10n_in_edi_production_env = fields.Boolean( + string="Indian Production Environment", + help="Enable the use of production credentials", + groups="base.group_system", + default=True, + ) + l10n_in_pan_entity_id = fields.Many2one( + related="partner_id.l10n_in_pan_entity_id", + string="PAN", + store=True, + readonly=False, + help="PAN enables the department to link all transactions of the person with the department.\n" + "These transactions include taxpayments, TDS/TCS credits, returns of income/wealth/gift/FBT," + "specified transactions, correspondence, and so on.\n" + "Thus, PAN acts as an identifier for the person with the tax department.", + ) + l10n_in_pan_type = fields.Selection(related="l10n_in_pan_entity_id.type", string="PAN Type") + l10n_in_tan = fields.Char(related="partner_id.l10n_in_tan", string="TAN", readonly=False) + l10n_in_gst_state_warning = fields.Char(related="partner_id.l10n_in_gst_state_warning") + + # TDS/TCS settings + l10n_in_tds_feature = fields.Boolean( + string="TDS", + compute="_compute_l10n_in_parent_based_features", + inverse="_inverse_l10n_in_tds_feature", + recursive=True, + store=True, + ) + l10n_in_tcs_feature = fields.Boolean( + string="TCS", + compute="_compute_l10n_in_parent_based_features", + inverse="_inverse_l10n_in_tcs_feature", + recursive=True, + store=True, + ) + l10n_in_withholding_account_id = fields.Many2one( + comodel_name='account.account', + string="TDS Account", + check_company=True, + ) + l10n_in_withholding_journal_id = fields.Many2one( + comodel_name='account.journal', + string="TDS Journal", + check_company=True, + ) + + # GST settings + l10n_in_is_gst_registered = fields.Boolean( + string="Registered Under GST", + compute="_compute_l10n_in_parent_based_features", + inverse="_inverse_l10n_in_is_gst_registered", + recursive=True, + store=True, + ) + l10n_in_gstin_status_feature = fields.Boolean(string="Check GST Number Status") + + def _inverse_l10n_in_tds_feature(self): + for company in self: + self._activate_l10n_in_taxes(['tds_group'], company, company.l10n_in_tds_feature) + + def _inverse_l10n_in_tcs_feature(self): + for company in self: + self._activate_l10n_in_taxes(['tcs_group'], company, company.l10n_in_tcs_feature) + + def _inverse_l10n_in_is_gst_registered(self): + for company in self: + gst_group_refs = [ + 'sgst_group', + 'cgst_group', + 'igst_group', + 'cess_group', + 'gst_group', + 'exempt_group', + 'nil_rated_group', + 'non_gst_supplies_group', + ] + if company.l10n_in_is_gst_registered: + self._activate_l10n_in_taxes(gst_group_refs, company, True) + # Set sale and purchase tax accounts when user registered under GST. + company.account_sale_tax_id = self.env['account.chart.template'].with_company(company).ref('sgst_sale_5', raise_if_not_found=False) + company.account_purchase_tax_id = self.env['account.chart.template'].with_company(company).ref('sgst_purchase_5', raise_if_not_found=False) + else: + self._activate_l10n_in_taxes(gst_group_refs, company, False) + company.account_sale_tax_id = False + company.account_purchase_tax_id = False + + @api.depends('parent_id.l10n_in_tds_feature', 'parent_id.l10n_in_tcs_feature', 'parent_id.l10n_in_is_gst_registered') + def _compute_l10n_in_parent_based_features(self): + for company in self: + if company.parent_id: + company.l10n_in_tds_feature = company.parent_id.l10n_in_tds_feature + company.l10n_in_tcs_feature = company.parent_id.l10n_in_tcs_feature + company.l10n_in_is_gst_registered = company.parent_id.l10n_in_is_gst_registered + + def _activate_l10n_in_taxes(self, group_refs, company, active=True): + tax_group_ids = [ + tax_group.id + for group_ref in group_refs + if (tax_group := self.env['account.chart.template'].with_company(company).ref(group_ref, raise_if_not_found=False)) + ] + + if tax_group_ids: + taxes = self.env['account.tax'].with_company(company).with_context(active_test=False).search([ + ('tax_group_id', 'in', tax_group_ids), + ('active', '!=', active) + ]) + taxes.write({'active': active}) + + @api.depends('vat') + def _compute_l10n_in_hsn_code_digit(self): + for record in self: + if record.country_code == "IN" and record.vat: + record.l10n_in_hsn_code_digit = "4" + else: + record.l10n_in_hsn_code_digit = False + + @api.onchange('vat') + def onchange_vat(self): + self.partner_id.onchange_vat() + + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) + # Update Fiscal Positions for new branch + res._update_l10n_in_fiscal_position() + return res + + def write(self, vals): + res = super().write(vals) + if vals.get('vat'): + # Enable GST(l10n_in_is_gst_registered) when a valid GSTIN(vat) is applied. + self._update_l10n_in_is_gst_registered() + if (vals.get('state_id') or vals.get('country_id')) and not self.env.context.get('delay_account_group_sync'): + # Update Fiscal Positions for companies setting up state for the first time + self._update_l10n_in_fiscal_position() + return res + + def _update_l10n_in_fiscal_position(self): + companies_need_update_fp = self.filtered(lambda c: c.parent_ids[0].chart_template == 'in') + for company in companies_need_update_fp: + ChartTemplate = self.env['account.chart.template'].with_company(company) + fiscal_position_data = ChartTemplate._get_in_account_fiscal_position() + for values in fiscal_position_data.values(): + values['tax_ids'] = [Command.set([ + xml_id + for xml_id in values['tax_ids'][0][2] + if ChartTemplate.ref(xml_id, raise_if_not_found=False) + ])] + ChartTemplate._load_data({'account.fiscal.position': fiscal_position_data}) + + def _update_l10n_in_is_gst_registered(self): + for company in self: + if company.country_code == "IN" and company.vat: + company.l10n_in_is_gst_registered = company.partner_id.check_vat_in(company.vat) + + def action_update_state_as_per_gstin(self): + self.ensure_one() + self.partner_id.action_update_state_as_per_gstin() + + def _check_tax_return_configuration(self): + """ + Check if the company is properly configured for tax returns. + :raises RedirectWarning: if something is wrong configured. + """ + + if self.country_code != 'IN': + return super()._check_tax_return_configuration() + + is_l10n_in_reports_installed = 'l10n_in_reports' in self.env['ir.module.module']._installed() + if not is_l10n_in_reports_installed: + msg = _("First enable GST e-Filing feature from configuration for company %s.", (self.name)) + action = self.env.ref("account.action_account_config") + raise RedirectWarning(msg, action.id, _('Go to configuration')) diff --git a/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/iap_account.py b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/iap_account.py new file mode 100644 index 0000000..6dd7f0d --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/iap_account.py @@ -0,0 +1,30 @@ +from odoo import api, models +from odoo.addons.iap import jsonrpc + +DEFAULT_IAP_ENDPOINT = "https://l10n-in-edi.api.odoo.com" +DEFAULT_IAP_TEST_ENDPOINT = "https://l10n-in-edi-demo.api.odoo.com" +IAP_SERVICE_NAME = 'l10n_in_edi' +TEST_GST_NUMBER = '24FANCY1234AAZA' + + +class IapAccount(models.Model): + _inherit = 'iap.account' + + @api.model + def _l10n_in_connect_to_server(self, is_production, params, url_path, config_parameter, timeout=25): + IrConfigParam = self.env['ir.config_parameter'].sudo() + user_token = self.get(IAP_SERVICE_NAME) + params.update({ + "dbuuid": IrConfigParam.get_param("database.uuid"), + "account_token": user_token.sudo().account_token, + }) + gsp_provider = IrConfigParam.get_param("l10n_in.gsp_provider") + if gsp_provider: + params.update({"gsp_provider": gsp_provider}) + if params.get('gstin') == TEST_GST_NUMBER: + default_endpoint = DEFAULT_IAP_TEST_ENDPOINT + else: + default_endpoint = DEFAULT_IAP_ENDPOINT if is_production else DEFAULT_IAP_TEST_ENDPOINT + endpoint = IrConfigParam.get_param(config_parameter, default_endpoint) + url = "%s%s" % (endpoint, url_path) + return jsonrpc(url, params=params, timeout=timeout) diff --git a/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/l10n_in_pan_entity.py b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/l10n_in_pan_entity.py new file mode 100644 index 0000000..0afd44a --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/l10n_in_pan_entity.py @@ -0,0 +1,92 @@ +import base64 + +from stdnum.in_ import pan +from odoo import api, fields, models, _ + +from odoo.exceptions import ValidationError + + +class L10nInPanEntity(models.Model): + _name = 'l10n_in.pan.entity' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _description = 'Indian PAN Entity' + + name = fields.Char(string="PAN", tracking=1, required=True) + type = fields.Selection([ + ('a', 'Association of Persons'), + ('b', 'Body of Individuals'), + ('c', 'Company'), + ('f', 'Firms'), + ('g', 'Government'), + ('h', 'Hindu Undivided Family'), + ('j', 'Artificial Judicial Person'), + ('l', 'Local Authority'), + ('p', 'Individual'), + ('t', 'Association of Persons for a Trust'), + ('k', 'Krish (Trust Krish)'), + ], compute='_compute_type', readonly=True, store=True) + partner_ids = fields.One2many( + comodel_name='res.partner', + inverse_name='l10n_in_pan_entity_id', + string="Partners", + domain="[('l10n_in_pan_entity_id', '=', False), '|', ('vat', '=', False), ('vat', 'like', name)]" + ) + tds_deduction = fields.Selection([ + ('normal', 'Normal'), + ('lower', 'Lower'), + ('higher', 'Higher'), + ('no', 'No'), + ], string="TDS Deduction", default='normal', tracking=2) + tds_certificate = fields.Binary(string="TDS Certificate", copy=False) + tds_certificate_filename = fields.Char(string="TDS Certificate Filename", copy=False) + + # MSME/Udyam Registration details + msme_type = fields.Selection([ + ("micro", "Micro"), + ("small", "Small"), + ("medium", "Medium") + ], string="MSME/Udyam Registration Type", copy=False) + msme_number = fields.Char(string="MSME/Udyam Registration Number", copy=False) + + _name_uniq = models.Constraint( + 'unique (name)', + 'A PAN Entity with same PAN Number already exists.', + ) + + @api.constrains('name') + def _check_pan_name(self): + if 'import_file' in self.env.context: + return + for record in self: + if record.name and not pan.is_valid(record.name): + raise ValidationError(_("The entered PAN %s seems invalid. Please enter a valid PAN.", record.name)) + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for record in records: + record.name = record.name.upper() + return records + + def write(self, vals): + if vals.get('name'): + vals['name'] = vals['name'].upper() + res = super().write(vals) + if vals.get('tds_certificate'): + for rec in self: + rec.message_post( + body=_("TDS Certificate Added"), + message_type='notification', + subtype_xmlid='mail.mt_note', + attachments=[(rec.tds_certificate_filename, base64.b64decode(vals['tds_certificate']))] + ) + return res + + @api.depends('name') + def _compute_type(self): + for record in self: + if record.name: + if pan.is_valid(record.name): + record.type = record.name[3].lower() + else: + record.type = False diff --git a/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/l10n_in_report_handler.py b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/l10n_in_report_handler.py new file mode 100644 index 0000000..915976a --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/l10n_in_report_handler.py @@ -0,0 +1,17 @@ +from odoo import models + + +class AccountReport(models.Model): + _inherit = 'account.report' + + def _init_options_buttons(self, options, previous_options): + super()._init_options_buttons(options, previous_options) + company = self.env.company + generic_report_id = self.env.ref('account.generic_tax_report').id + + # Remove 'Returns' button from generic report for indian company + if company.country_id.code == 'IN' and self.id == generic_report_id and not self.root_report_id: + options['buttons'] = [ + button for button in options['buttons'] + if button.get('action') != 'action_open_returns' + ] diff --git a/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/l10n_in_section_alert.py b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/l10n_in_section_alert.py new file mode 100644 index 0000000..e87c8a5 --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/l10n_in_section_alert.py @@ -0,0 +1,50 @@ +from odoo import _, api, fields, models + + +class L10n_InSectionAlert(models.Model): + _name = 'l10n_in.section.alert' + _description = "indian section alert" + + name = fields.Char("Section Name") + tax_source_type = fields.Selection([ + ('tds', 'TDS'), + ('tcs', 'TCS'), + ], string="Tax Source Type") + consider_amount = fields.Selection([ + ('untaxed_amount', 'Untaxed Amount'), + ('total_amount', 'Total Amount'), + ], string="Consider", default='untaxed_amount', required=True) + is_per_transaction_limit = fields.Boolean("Per Transaction") + per_transaction_limit = fields.Float("Per Transaction limit") + is_aggregate_limit = fields.Boolean("Aggregate") + aggregate_limit = fields.Float("Aggregate limit") + aggregate_period = fields.Selection([ + ('monthly', 'Monthly'), + ('fiscal_yearly', 'Financial Yearly'), + ], string="Aggregate Period", default='fiscal_yearly') + l10n_in_section_tax_ids = fields.One2many("account.tax", "l10n_in_section_id", string="Taxes") + tax_report_line_id = fields.Many2one(string="Tax Report Line", comodel_name='account.report.line') + + _per_transaction_limit = models.Constraint( + 'CHECK(per_transaction_limit >= 0)', + 'Per transaction limit must be positive', + ) + _aggregate_limit = models.Constraint( + 'CHECK(aggregate_limit >= 0)', + 'Aggregate limit must be positive', + ) + + @api.depends('tax_source_type') + def _compute_display_name(self): + for record in self: + record.display_name = f"{record.tax_source_type.upper()} {record.name or ''}" if record.tax_source_type else f"{record.name or ''}" + + def _get_warning_message(self): + warning = ", ".join(self.mapped('name')) + section_type = next(iter(set(self.mapped('tax_source_type')))).upper() + action = _('collect') if section_type == 'TCS' else _('deduct') + return _("It's advisable to %(action)s %(section_type)s u/s %(warning)s on this transaction.", + action=action, + section_type=section_type, + warning=warning + ) diff --git a/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/mail_message.py b/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/mail_message.py deleted file mode 100644 index c85dd76..0000000 --- a/odoo-bringout-oca-ocb-l10n_in/l10n_in/models/mail_message.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from markupsafe import Markup - -from odoo import fields, api, models, _ -from odoo.exceptions import UserError - - -class Message(models.Model): - _inherit = 'mail.message' - - l10n_in_audit_log_preview = fields.Html(string="Description", compute="_compute_l10n_in_audit_log_preview") - l10n_in_audit_log_account_move_id = fields.Many2one('account.move', string="Journal Entry", compute="_compute_l10n_in_audit_log_document_name", search="_search_l10n_in_audit_log_document_name") - - @api.depends('body', 'subject', 'tracking_value_ids', 'subtype_id') - def _compute_l10n_in_audit_log_preview(self): - for message in self: - title = message.subject or message.preview - tracking_value_ids = message.sudo().tracking_value_ids - if not title and tracking_value_ids: - title = _("Updated") - elif not title and message.subtype_id and not message.subtype_id.internal: - title = message.subtype_id.display_name - audit_log_preview = Markup("%s ") % (title)
- for value in tracking_value_ids:
- audit_log_preview += Markup(
- "
+
+
+
+
+
+
+
+
+
+
+
+
+
GSTIN:
+ - Total (In Words): - - -HSN/SAC |
-
+ |
+
|
- - Draft - Cancelled - Credit Note - Vendor Credit Note - Vendor Bill - -+
+ |
+ PAYMENT QR CODE
+
+
+
+ UPI ID: + +
+
+
+
+ HSN Summary+
+
Place of supply:
diff --git a/odoo-bringout-oca-ocb-l10n_in/l10n_in/views/report_template.xml b/odoo-bringout-oca-ocb-l10n_in/l10n_in/views/report_template.xml
deleted file mode 100644
index 0725936..0000000
--- a/odoo-bringout-oca-ocb-l10n_in/l10n_in/views/report_template.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
-
-
-
+
+
diff --git a/odoo-bringout-oca-ocb-l10n_in/l10n_in/views/res_country_state_view.xml b/odoo-bringout-oca-ocb-l10n_in/l10n_in/views/res_country_state_view.xml
index 282d504..120d5a1 100644
--- a/odoo-bringout-oca-ocb-l10n_in/l10n_in/views/res_country_state_view.xml
+++ b/odoo-bringout-oca-ocb-l10n_in/l10n_in/views/res_country_state_view.xml
@@ -7,7 +7,18 @@
- Use this if setup with Reseller(E-Commerce).
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+ Changes to this configuration will be automatically applied to all Indian companies.
+ Please refer to the documentation for detailed guidance on accurate configuration.
+
+
+ The current GSP is scheduled for deprecation soon.
+ We strongly recommend switching to BVM GSP at the earliest to ensure uninterrupted service.
+
+
+
+
+
+
+
+
+
+
+
+ Save this page and come back here to set up the feature.
+
+
+
+
+ Save this page and come back here to set up the feature.
+
+
+
-
+
+
+
+
+
+ PAN number is not same as the 3rd to 12th characters of the GST number.
+
+
+ Normally, " -"this should not happen too often Just verify by logging into government " -"website here." -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. Normally, " -"this should not happen too often Just verify value of invoice by uploade" -" json to government website here." +"Set an appropriate GST tax on invoice lines (if it's zero rated or nil rated" +" then apply it too)" 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" +"Somehow this invoice had been cancelled to government before.%(br)sNormally," +" this should not happen too often%(br)sJust verify by logging into " +"government website %(link)s" msgstr "" #. module: l10n_in_edi #. odoo-python -#: code:addons/l10n_in_edi/models/account_edi_format.py:0 -#, python-format +#: code:addons/l10n_in_edi/models/account_move.py:0 +msgid "" +"Somehow this invoice has been submited to government before.%(br)sNormally, " +"this should not happen too often%(br)sJust verify value of invoice by upload" +" json to government website %(link)s." +msgstr "" + +#. module: l10n_in_edi +#. odoo-python +#: code:addons/l10n_in_edi/models/account_move.py:0 +msgid "This electronic document is being processed already." +msgstr "" + +#. module: l10n_in_edi +#: model:ir.model.fields.selection,name:l10n_in_edi.selection__account_move__l10n_in_edi_status__to_send +msgid "To Send" +msgstr "" + +#. module: l10n_in_edi +#. odoo-python +#: code:addons/l10n_in_edi/models/res_company.py:0 +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 +#. odoo-python +#: code:addons/l10n_in_edi/models/account_move.py:0 msgid "" "Unable to connect to the online E-invoice service.The web service may be " "temporary down. Please try again in a moment." @@ -381,31 +577,52 @@ 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!" +#: code:addons/l10n_in_edi/models/res_company.py:0 +#: code:addons/l10n_in_edi/models/res_partner.py:0 +msgid "View %s" 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" +#: code:addons/l10n_in_edi/models/res_company.py:0 +msgid "View Companies" 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" +#: code:addons/l10n_in_edi/models/account_move_line.py:0 +msgid "View Invoice Line(s)" 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" +#. odoo-python +#: code:addons/l10n_in_edi/models/account_move.py:0 +msgid "View Invoices" +msgstr "" + +#. module: l10n_in_edi +#. odoo-python +#: code:addons/l10n_in_edi/models/res_partner.py:0 +msgid "View Partners" +msgstr "" + +#. module: l10n_in_edi +#. odoo-python +#: code:addons/l10n_in_edi/models/ir_attachment.py:0 +msgid "You can't unlink an attachment that you received from the government" +msgstr "" + +#. module: l10n_in_edi +#. odoo-python +#: code:addons/l10n_in_edi/models/account_move.py:0 +msgid "here" +msgstr "" + +#. module: l10n_in_edi +#. odoo-python +#: code:addons/l10n_in_edi/models/account_move.py:0 +msgid "" +"⚠️ Important Notice – GSP Deprecation \n" +"The currently selected GSP (Tera Soft) will be deprecated soon.\n" +"To ensure uninterrupted e-Invoice and E-way operations, please switch to BVM GSP as per the" msgstr "" diff --git a/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/models/__init__.py b/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/models/__init__.py index 26d01b7..173b23a 100644 --- a/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/models/__init__.py +++ b/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/models/__init__.py @@ -1,7 +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 account_move_line +from . import account_move_send +from . import ir_attachment from . import res_company from . import res_config_settings +from . import res_partner diff --git a/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/models/account_edi_format.py b/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/models/account_edi_format.py deleted file mode 100644 index c05d754..0000000 --- a/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/models/account_edi_format.py +++ /dev/null @@ -1,736 +0,0 @@ -# -*- 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(""" %s %s %s """) % ( - _("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." \ - "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: - return {invoice: { - "success": False, - "error": self._l10n_in_edi_get_iap_buy_credits_message(invoice.company_id), - "blocking_level": "error", - }} - elif error: - error_message = " ".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." \ - " Normally, this should not happen too often" \ - " Just verify by logging into government website " \ - "here." - )) - 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 = " ".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) diff --git a/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/models/account_move.py b/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/models/account_move.py index 0909865..f915576 100644 --- a/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/models/account_move.py +++ b/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/models/account_move.py @@ -1,79 +1,843 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - +import base64 import json +import logging +import re +from collections import defaultdict + from markupsafe import Markup -from odoo import api, fields, models, _ -from odoo.exceptions import UserError +from odoo import Command, _, api, fields, models +from odoo.exceptions import AccessError, LockError, UserError +from odoo.tools import float_is_zero, float_compare + +from odoo.addons.l10n_in.models.account_invoice import EDI_CANCEL_REASON + +_logger = logging.getLogger(__name__) +try: + import jwt +except ImportError: + _logger.warning("The 'jwt' library is not installed. Decoding for duplicate IRN e-invoices will be skipped.") + jwt = None 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?") + # E-Invoice Fields + l10n_in_edi_status = fields.Selection( + string="India E-Invoice Status", + selection=[ + ('to_send', "To Send"), + ('sent', "Sent"), + ('cancelled', "Cancelled"), + ], + copy=False, + tracking=True, + readonly=True, + ) + l10n_in_edi_attachment_id = fields.Many2one( + comodel_name='ir.attachment', + string="E-Invoice(IN) Attachment", + compute=lambda self: self._compute_linked_attachment_id( + 'l10n_in_edi_attachment_id', + 'l10n_in_edi_attachment_file' + ), + depends=['l10n_in_edi_attachment_file'] + ) + l10n_in_edi_attachment_file = fields.Binary( + string="E-Invoice(IN) File", + attachment=True, + copy=False + ) + l10n_in_edi_cancel_reason = fields.Selection( + selection=list(EDI_CANCEL_REASON.items()), + string="E-Invoice(IN) Cancel Reason", + copy=False + ) + l10n_in_edi_cancel_remarks = fields.Char( + string="E-Invoice(IN) Cancel Remarks", + copy=False + ) + l10n_in_edi_content = fields.Binary( + compute="_compute_l10n_in_edi_content", + string="E-Invoice(IN) Content" + ) + l10n_in_edi_error = fields.Html(readonly=True, copy=False) - @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): + # E-Invoice compute + def _compute_l10n_in_edi_content(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(" "), - edi_codes=Markup(" ").join( - move.edi_document_ids - .filtered(lambda doc: doc.blocking_level == "error") - .mapped("edi_format_name") - ) - )) - return super().action_retry_edi_documents_error() + move.l10n_in_edi_content = ( + move.country_code == 'IN' + and move.company_id.l10n_in_edi_feature + and move.is_sale_document(include_receipts=True) + and move.journal_id.type == 'sale' + and base64.b64encode( + json.dumps(move._l10n_in_edi_generate_invoice_json()).encode() + ) + ) - 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"] + def _compute_l10n_in_warning(self): + super()._compute_l10n_in_warning() + gsp_provider = self.env["ir.config_parameter"].sudo().get_param("l10n_in.gsp_provider", "tera") + if gsp_provider != "tera": + return + indian_invoice = self.filtered(lambda m: m.country_code == 'IN' and m.move_type != 'entry' and + m.l10n_in_edi_status in ('to_send', 'sent') and not m.l10n_in_edi_error + ) + edi_error_message = _( + "⚠️ Important Notice – GSP Deprecation \n" + "The currently selected GSP (Tera Soft) will be deprecated soon.\n" + "To ensure uninterrupted e-Invoice and E-way operations, please switch to BVM GSP as per the" + ) + if not self.env.is_admin(): + edi_error_message += _( + "\n\nYou must contact your system administrator to update the GSP." + ) + for move in indian_invoice: + l10n_in_warning = move.l10n_in_warning or {} + l10n_in_warning['in_edi_gsp_deprecation'] = { + 'message': edi_error_message, + 'action_text': _("Documentation"), + 'action': { + 'name': _("Documentation"), + 'type': 'ir.actions.act_url', + 'url': 'https://www.odoo.com/documentation/19.0/applications/finance/fiscal_localizations/india.html#gsp-configuration', + } + } + move.l10n_in_warning = l10n_in_warning + + # Action Methods + def action_export_l10n_in_edi_content_json(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_url', + 'url': f'/web/content/account.move/{self.id}/l10n_in_edi_content' + } + + def button_request_cancel(self): + if self._l10n_in_edi_need_cancel_request(): + if self.l10n_in_edi_cancel_remarks and self.l10n_in_edi_cancel_reason: + return self._l10n_in_edi_cancel_invoice() + return self.env['l10n_in_edi.cancel'].with_context( + default_move_id=self.id + )._get_records_action(name=_("Cancel E-Invoice"), target='new') + elif self.l10n_in_edi_status == 'sent': + self.message_post( + body=_( + "Force cancelled %(invoice)s by %(username)s", + invoice=self.name, username=self.env.user.name + ) + ) + self.button_cancel() + self.write({ + 'l10n_in_edi_status': 'cancelled', + 'l10n_in_edi_error': False, + }) + return True + return super().button_request_cancel() + + def action_l10n_in_edi_force_cancel(self): + self.with_context(l10n_in_edi_force_cancel=True).button_request_cancel() + + def button_draft(self): 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() + if move.l10n_in_edi_status == 'to_send': + # Avoid resetting sent and cancelled invoices + move.l10n_in_edi_status = False + if move.l10n_in_edi_error: + move.l10n_in_edi_error = False + return super().button_draft() + + # Business Methods + def _post(self, soft=True): + # EXTENDS 'account' + res = super()._post(soft=soft) + self.filtered(lambda m: m._l10n_in_check_einvoice_eligible()).l10n_in_edi_status = 'to_send' + return res + + def _l10n_in_edi_need_cancel_request(self): + self.ensure_one() + return ( + self.country_code == 'IN' + and not self.env.context.get('l10n_in_edi_force_cancel') + and self.is_sale_document() + and self.l10n_in_edi_status == 'sent' + ) + + def _need_cancel_request(self): + # EXTENDS 'account' + return super()._need_cancel_request() or self._l10n_in_edi_need_cancel_request() + + # Indian E-invoice Business Methods + def _l10n_in_check_einvoice_eligible(self): + self.ensure_one() + return ( + self.company_id.l10n_in_edi_feature + and self.journal_id.type == 'sale' + and any( + line.display_type == 'product' + and line.l10n_in_gstr_section in [ + 'sale_b2b_rcm', 'sale_b2b_regular', 'sale_exp_wp', 'sale_exp_wop', + 'sale_sez_wp', 'sale_sez_wop', 'sale_deemed_export', 'sale_cdnr_rcm', + 'sale_cdnr_regular', 'sale_cdnr_deemed_export', 'sale_cdnr_sez_wp', + 'sale_cdnr_sez_wop', 'sale_cdnur_exp_wp', 'sale_cdnur_exp_wop', + ] + for line in self.line_ids + ) + ) 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 {} + if self.l10n_in_edi_attachment_id: + return json.loads(self.l10n_in_edi_attachment_id.sudo().raw.decode("utf-8")) + return {} + + def _l10n_in_lock_invoice(self): + try: + self.lock_for_update() + except LockError: + raise UserError(_('This electronic document is being processed already.')) from None + + def _l10n_in_edi_optional_field_validation(self, partner): + """ + Validates optional partner fields (e.g., email, phone, street2) for e-invoicing, + which are not mandatory in the government API JSON schema. Returns error messages + for posting in the chatter. + """ + message = [] + if partner.email and ( + not re.match(r"^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$", partner.email) or + not re.match(r"^.{6,100}$", partner.email) + ): + message.append(_("- Email: invalid or longer than 100 characters.")) + if partner.phone and not re.match( + r"^[0-9]{10,12}$", + partner.env['account.move']._l10n_in_extract_digits(partner.phone) + ): + message.append(_("- Phone number: must be 10–12 digits.")) + if partner.street2 and not re.match(r"^.{3,100}$", partner.street2): + message.append(_("- Street2: must be 3–100 characters.")) + return message + + def _l10n_in_edi_send_invoice(self): + self.ensure_one() + if self.l10n_in_edi_error: + # make sure to clear the error before sending again + self.l10n_in_edi_error = False + self.message_post(body=_( + "Retrying to send your E-Invoice to government portal." + )) + partners = set(self._get_l10n_in_seller_buyer_party().values()) + for partner in partners: + if partner_validation := partner._l10n_in_edi_strict_error_validation(): + self.l10n_in_edi_error = Markup(" ").join(partner_validation) + return {'messages': partner_validation} + self._l10n_in_lock_invoice() + generate_json = self._l10n_in_edi_generate_invoice_json() + response = self._l10n_in_edi_connect_to_server( + url_end_point='generate', + json_payload=generate_json + ) + if error := response.get('error', {}): + odoobot_id = self.env.ref('base.partner_root').id + 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_connect_to_server( + url_end_point='getirnbydocdetails', + params={ + "doc_type": ( + (self.move_type == "out_refund" and "CRN") + or (self.debit_origin_id and "DBN") + or "INV" + ), + "doc_num": self.name, + "doc_date": self.invoice_date and self.invoice_date.strftime("%d/%m/%Y"), + } + ) + mismatch_error = [] + decoded_response = {} + if jwt: + try: + if data := response.get('data'): + signinvoice = data['SignedInvoice'] + decoded_response = jwt.decode(signinvoice, options={'verify_signature': False}) + decoded_response = json.loads(decoded_response['data']) + except (json.JSONDecodeError, jwt.exceptions.DecodeError) as e: + _logger.warning("Failed to decode SignedInvoice JWT payload: %s", str(e)) + if decoded_response: + received_gstin = decoded_response['BuyerDtls']['Gstin'] + expected_gstin = generate_json['BuyerDtls']['GSTIN'] + received_total_invoice_value = decoded_response['ValDtls']['TotInvVal'] + expected_total_invoice_value = generate_json['ValDtls']['TotInvVal'] + # Check for mismatch between decoded and expected e-invoice details: + # - Buyer GSTIN must match + # - Total Invoice Value must be within the allowed government tolerance range: + # For example, if the expected invoice value is 100, + # the valid range is from 99 (value - 1) to 101 (value + 1), + # i.e., 99.00 < received value < 101.00 + if (received_gstin != expected_gstin or + not expected_total_invoice_value - 1 < received_total_invoice_value < expected_total_invoice_value + 1 + ): + mismatch_error = [{ + 'code': '2150', + 'message': _("Duplicate IRN found for this invoice, but the buyer details or invoice values do not match.") + }] + # Handle the result based on mismatch or response error + if mismatch_error: + error = mismatch_error + elif not response.get("error"): + error = [] + link = Markup( + "%s" + ) % (_("here")) + self.message_post( + author_id=odoobot_id, + body=_( + "Somehow this invoice has been submited to government before." + "%(br)sNormally, this should not happen too often" + "%(br)sJust verify value of invoice by upload json to government website %(link)s.", + br=Markup(" "), + link=link + ) + ) + if (no_credit := 'no-credit' in error_codes) or error: + msg = Markup(" ").join( + ["[%s] %s" % (e.get("code"), e.get("message")) for e in error] + ) + is_warning = any(warning_code in error_codes for warning_code in ('404', 'timeout')) + self.l10n_in_edi_error = ( + self._l10n_in_edi_get_iap_buy_credits_message() + if no_credit else msg + ) + # avoid return `l10n_in_edi_error` because as a html field + # values are sanitized with ` ` tag
+ return {
+ 'messages': [msg],
+ 'is_warning': is_warning
+ }
+ data = response.get("data", {})
+ json_dump = json.dumps(data)
+ json_name = "%s_einvoice.json" % (self.name.replace("/", "_"))
+ attachment = self.env["ir.attachment"].create({
+ 'name': json_name,
+ 'raw': json_dump.encode(),
+ 'res_model': self._name,
+ 'res_field': 'l10n_in_edi_attachment_file',
+ 'res_id': self.id,
+ 'mimetype': 'application/json',
+ 'company_id': self.company_id.id,
+ })
+ request_json_dump = json.dumps(generate_json, indent=4)
+ request_json_name = "%s_request.json" % (self.name.replace("/", "_"))
+ request_attachment = self.env["ir.attachment"].create({
+ 'name': request_json_name,
+ 'raw': request_json_dump.encode(),
+ 'res_model': self._name,
+ 'res_id': self.id,
+ 'mimetype': 'application/json',
+ 'company_id': self.company_id.id,
+ })
+ self.l10n_in_edi_status = 'sent'
+ message = []
+ for partner in partners:
+ if partner_validation := self._l10n_in_edi_optional_field_validation(partner):
+ message.append(
+ Markup("%s
- Acknowledgement:
-
-
+
+ Acknowledgement
+
+
-
-
+
+
diff --git a/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/views/res_config_settings_views.xml b/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/views/res_config_settings_views.xml
index 13b1ff2..1bf6142 100644
--- a/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/views/res_config_settings_views.xml
+++ b/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/views/res_config_settings_views.xml
@@ -5,40 +5,24 @@
IRN:
-
- Indian Electronic Invoicing-
-
-
+
- Setup E-invoice
-
-
+
- Check the documentation to get credentials
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
+
+
+
+
+
+
+
diff --git a/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/wizard/__init__.py b/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/wizard/__init__.py
new file mode 100644
index 0000000..a524696
--- /dev/null
+++ b/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/wizard/__init__.py
@@ -0,0 +1 @@
+from . import l10n_in_edi_cancel
diff --git a/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/wizard/l10n_in_edi_cancel.py b/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/wizard/l10n_in_edi_cancel.py
new file mode 100644
index 0000000..51715fd
--- /dev/null
+++ b/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/wizard/l10n_in_edi_cancel.py
@@ -0,0 +1,25 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo import fields, models
+
+from odoo.addons.l10n_in.models.account_invoice import EDI_CANCEL_REASON
+
+
+class L10n_In_EdiCancel(models.TransientModel):
+ _name = 'l10n_in_edi.cancel'
+
+ _description = "Cancel E-Invoice"
+
+ move_id = fields.Many2one('account.move', string="Invoice", required=True)
+ cancel_reason = fields.Selection(
+ selection=list(EDI_CANCEL_REASON.items()),
+ string="Cancel Reason",
+ required=True
+ )
+ cancel_remarks = fields.Char("Cancel Remarks", required=True)
+
+ def cancel_l10n_in_edi_move(self):
+ self.move_id.write({
+ 'l10n_in_edi_cancel_reason': self.cancel_reason,
+ 'l10n_in_edi_cancel_remarks': self.cancel_remarks,
+ })
+ self.move_id._l10n_in_edi_cancel_invoice()
diff --git a/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/wizard/l10n_in_edi_cancel_views.xml b/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/wizard/l10n_in_edi_cancel_views.xml
new file mode 100644
index 0000000..388acfd
--- /dev/null
+++ b/odoo-bringout-oca-ocb-l10n_in_edi/l10n_in_edi/wizard/l10n_in_edi_cancel_views.xml
@@ -0,0 +1,30 @@
+
++ Create a new product variant + ++ You must define an HSN for every product you sell through + the point of sale interface. + + """, + }) + self.env['ir.model.data']._update_xmlids([{ + 'xml_id': action_xml_id, + 'record': action, + }]) + + return action_xml_id diff --git a/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/models/product_template.py b/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/models/product_template.py new file mode 100644 index 0000000..b8a8f92 --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/models/product_template.py @@ -0,0 +1,14 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + @api.model + def _load_pos_data_fields(self, config): + fields = super()._load_pos_data_fields(config) + if self.env.company.country_id.code == 'IN': + fields += ['l10n_in_hsn_code'] + return fields diff --git a/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/models/res_company.py b/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/models/res_company.py new file mode 100644 index 0000000..894094d --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/models/res_company.py @@ -0,0 +1,14 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + @api.model + def _load_pos_data_fields(self, config): + fields = super()._load_pos_data_fields(config) + if self.env.company.country_id.code == 'IN': + fields += ['l10n_in_is_gst_registered'] + return fields diff --git a/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/static/src/app/components/popups/cash_move_popup/cash_move_popup.js b/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/static/src/app/components/popups/cash_move_popup/cash_move_popup.js new file mode 100644 index 0000000..7208422 --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/static/src/app/components/popups/cash_move_popup/cash_move_popup.js @@ -0,0 +1,15 @@ +import { CashMovePopup } from "@point_of_sale/app/components/popups/cash_move_popup/cash_move_popup"; + +import { patch } from "@web/core/utils/patch"; +import { companyStateDialog } from "@l10n_in_pos/app/components/popups/company_state_dialog/company_state_dialog"; + +patch(CashMovePopup.prototype, { + async confirm() { + await this.pos.data.read("res.company", [this.pos.company.id]); + if (this.pos.company.country_id?.code === "IN" && !this.pos.company.state_id) { + this.dialog.add(companyStateDialog); + return; + } + return await super.confirm(); + }, +}); diff --git a/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/static/src/app/components/popups/closing_popup/closing_popup.js b/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/static/src/app/components/popups/closing_popup/closing_popup.js new file mode 100644 index 0000000..b1336db --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/static/src/app/components/popups/closing_popup/closing_popup.js @@ -0,0 +1,34 @@ +import { ClosePosPopup } from "@point_of_sale/app/components/popups/closing_popup/closing_popup"; + +import { patch } from "@web/core/utils/patch"; +import { useService } from "@web/core/utils/hooks"; +import { companyStateDialog } from "@l10n_in_pos/app/components/popups/company_state_dialog/company_state_dialog"; +import { hsnCodeDialog } from "@l10n_in_pos/app/components/popups/hsn_code_dialog/hsn_code_dialog"; + +patch(ClosePosPopup.prototype, { + setup() { + super.setup(); + this.orm = useService("orm"); + }, + + async confirm() { + await this.pos.data.read("res.company", [this.pos.company.id]); + if (this.pos.company.country_id?.code === "IN" && !this.pos.company.state_id) { + this.dialog.add(companyStateDialog); + return; + } + + if (this.pos.company.l10n_in_is_gst_registered) { + const missingHsnLines = await this.orm.call( + "pos.session", + "set_missing_hsn_codes_in_pos_orders", + [this.pos.session.id] + ); + if (missingHsnLines?.length) { + this.dialog.add(hsnCodeDialog); + return; + } + } + return await super.confirm(); + }, +}); diff --git a/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/static/src/app/components/popups/company_state_dialog/company_state_dialog.js b/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/static/src/app/components/popups/company_state_dialog/company_state_dialog.js new file mode 100644 index 0000000..5d6d846 --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/static/src/app/components/popups/company_state_dialog/company_state_dialog.js @@ -0,0 +1,23 @@ +import { Dialog } from "@web/core/dialog/dialog"; +import { Component } from "@odoo/owl"; +import { usePos } from "@point_of_sale/app/hooks/pos_hook"; + +export class companyStateDialog extends Component { + static components = { Dialog }; + static template = "l10n_in_pos.companyStateDialog"; + static props = { + close: Function, + }; + + setup() { + this.pos = usePos(); + } + + redirect() { + window.location = "/odoo/companies/" + this.pos.company.id; + } + + onClose() { + this.props.close(); + } +} diff --git a/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/static/src/app/components/popups/company_state_dialog/company_state_dialog.xml b/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/static/src/app/components/popups/company_state_dialog/company_state_dialog.xml new file mode 100644 index 0000000..afc6885 --- /dev/null +++ b/odoo-bringout-oca-ocb-l10n_in_pos/l10n_in_pos/static/src/app/components/popups/company_state_dialog/company_state_dialog.xml @@ -0,0 +1,23 @@ + +
+ Phone:
+
+ +
+ HSN Code:
+
+
-
-
- Phone:
-
- -
- HSN Code:
-
- + Create a new product variant + ++ You must define an HSN for every product you sell through + the point of sale interface. + +
+ Please note that the kiosk for INR currency only works with Razorpay terminal
+
+ HSN/SAC Code:-HSN/SAC Code:+ HSN/SAC Code: +
+
+
+
+
+
+
+
+ Prepared By:
+
+
+ Approved By:
+
+ +
+
+
+ Received By:
+
+
+ Received Date:
+
+ +
+
+
+ Check Number:
+
+
+
+ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|