19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:21 +01:00
parent 7dc55599c6
commit 7f43bbbfcc
650 changed files with 45260 additions and 33436 deletions

View file

@ -1,13 +1,19 @@
# -*- coding:utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account
from . import template_in
from . import account_invoice
from . import chart_template
from . import account_journal
from . import account_move_line
from . import account_payment
from . import account_tax
from . import company
from . import iap_account
from . import product_template
from . import port_code
from . import res_config_settings
from . import res_country_state
from . import res_partner
from . import uom_uom
from . import mail_message
from . import account_account
from . import l10n_in_section_alert
from . import l10n_in_pan_entity
from . import l10n_in_report_handler

View file

@ -1,77 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo import tools
class AccountJournal(models.Model):
_inherit = "account.journal"
# Use for filter import and export type.
l10n_in_gstin_partner_id = fields.Many2one('res.partner', string="GSTIN Unit", ondelete="restrict", help="GSTIN related to this journal. If empty then consider as company GSTIN.")
def name_get(self):
"""
Add GSTIN number in name as suffix so user can easily find the right journal.
Used super to ensure nothing is missed.
"""
result = super().name_get()
result_dict = dict(result)
indian_journals = self.filtered(lambda j: j.company_id.account_fiscal_country_id.code == 'IN' and
j.l10n_in_gstin_partner_id and j.l10n_in_gstin_partner_id.vat)
for journal in indian_journals:
name = result_dict[journal.id]
name += "- %s" % (journal.l10n_in_gstin_partner_id.vat)
result_dict[journal.id] = name
return list(result_dict.items())
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
def init(self):
tools.create_index(self._cr, 'account_move_line_move_product_index', self._table, ['move_id', 'product_id'])
@api.depends('move_id.line_ids', 'move_id.line_ids.tax_line_id', 'move_id.line_ids.debit', 'move_id.line_ids.credit')
def _compute_tax_base_amount(self):
aml = self.filtered(lambda l: l.company_id.account_fiscal_country_id.code == 'IN' and l.tax_line_id and l.product_id)
for move_line in aml:
base_lines = move_line.move_id.line_ids.filtered(lambda line: move_line.tax_line_id in line.tax_ids and move_line.product_id == line.product_id)
move_line.tax_base_amount = abs(sum(base_lines.mapped('balance')))
remaining_aml = self - aml
if remaining_aml:
return super(AccountMoveLine, remaining_aml)._compute_tax_base_amount()
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")
@api.model
def _get_generation_dict_from_base_line(self, line_vals, tax_vals, force_caba_exigibility=False):
# EXTENDS account
# Group taxes also by product.
res = super()._get_generation_dict_from_base_line(line_vals, tax_vals, force_caba_exigibility=force_caba_exigibility)
record = line_vals['record']
if isinstance(record, models.Model)\
and record._name == 'account.move.line'\
and record.company_id.account_fiscal_country_id.code == 'IN':
res['product_id'] = record.product_id.id
res['product_uom_id'] = record.product_uom_id.id
return res
@api.model
def _get_generation_dict_from_tax_line(self, line_vals):
# EXTENDS account
# Group taxes also by product.
res = super()._get_generation_dict_from_tax_line(line_vals)
record = line_vals['record']
if isinstance(record, models.Model)\
and record._name == 'account.move.line'\
and record.company_id.account_fiscal_country_id.code == 'IN':
res['product_id'] = record.product_id.id
res['product_uom_id'] = record.product_uom_id.id
return res

View file

@ -0,0 +1,15 @@
from odoo import api, fields, models
class AccountAccount(models.Model):
_inherit = 'account.account'
l10n_in_tds_tcs_section_id = fields.Many2one('l10n_in.section.alert', string="TCS/TDS Section")
l10n_in_tds_feature_enabled = fields.Boolean(compute='_compute_tds_tcs_features', store=True)
l10n_in_tcs_feature_enabled = fields.Boolean(compute='_compute_tds_tcs_features', store=True)
@api.depends('company_ids.l10n_in_tds_feature', 'company_ids.l10n_in_tcs_feature')
def _compute_tds_tcs_features(self):
for record in self:
record.l10n_in_tds_feature_enabled = any(company.l10n_in_tds_feature for company in record.company_ids)
record.l10n_in_tcs_feature_enabled = any(company.l10n_in_tcs_feature for company in record.company_ids)

View file

@ -1,19 +1,34 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import logging
import json
import re
from odoo import api, fields, models, _
from contextlib import contextmanager
from markupsafe import Markup
from odoo import Command, _, api, fields, models
from odoo.exceptions import ValidationError, RedirectWarning, UserError
from odoo.tools.float_utils import json_float_round
from odoo.tools.image import image_data_uri
from odoo.tools import float_compare, SQL
from odoo.tools.date_utils import get_month
from odoo.addons.l10n_in.models.iap_account import IAP_SERVICE_NAME
EDI_CANCEL_REASON = {
# Same for both e-way bill and IRN
'1': "Duplicate",
'2': "Data Entry Mistake",
'3': "Order Cancelled",
'4': "Others",
}
_logger = logging.getLogger(__name__)
class AccountMove(models.Model):
_inherit = "account.move"
amount_total_words = fields.Char("Total (In Words)", compute="_compute_amount_total_words")
l10n_in_gst_treatment = fields.Selection([
l10n_in_gst_treatment = fields.Selection(
selection=[
('regular', 'Registered Business - Regular'),
('composition', 'Registered Business - Composition'),
('unregistered', 'Unregistered Business'),
@ -22,57 +37,517 @@ class AccountMove(models.Model):
('special_economic_zone', 'Special Economic Zone'),
('deemed_export', 'Deemed Export'),
('uin_holders', 'UIN Holders'),
], string="GST Treatment", compute="_compute_l10n_in_gst_treatment", store=True, readonly=False, copy=True)
l10n_in_state_id = fields.Many2one('res.country.state', string="Place of supply", compute="_compute_l10n_in_state_id", store=True, readonly=False)
],
string="GST Treatment",
compute="_compute_l10n_in_gst_treatment",
store=True,
readonly=False,
copy=True,
precompute=True
)
l10n_in_state_id = fields.Many2one(
comodel_name='res.country.state',
string="Place of supply",
compute="_compute_l10n_in_state_id",
store=True,
copy=True,
readonly=False,
precompute=True
)
l10n_in_gstin = fields.Char(string="GSTIN")
# For Export invoice this data is need in GSTR report
l10n_in_shipping_bill_number = fields.Char('Shipping bill number', readonly=True, states={'draft': [('readonly', False)]})
l10n_in_shipping_bill_date = fields.Date('Shipping bill date', readonly=True, states={'draft': [('readonly', False)]})
l10n_in_shipping_port_code_id = fields.Many2one('l10n_in.port.code', 'Port code', readonly=True, states={'draft': [('readonly', False)]})
l10n_in_reseller_partner_id = fields.Many2one('res.partner', 'Reseller', domain=[('vat', '!=', False)], help="Only Registered Reseller", readonly=True, states={'draft': [('readonly', False)]})
l10n_in_shipping_bill_number = fields.Char('Shipping bill number')
l10n_in_shipping_bill_date = fields.Date('Shipping bill date')
l10n_in_shipping_port_code_id = fields.Many2one('l10n_in.port.code', 'Port code')
l10n_in_reseller_partner_id = fields.Many2one(
comodel_name='res.partner',
string="Reseller",
domain=[('vat', '!=', False)],
help="Only Registered Reseller"
)
l10n_in_journal_type = fields.Selection(string="Journal Type", related='journal_id.type')
l10n_in_warning = fields.Json(compute="_compute_l10n_in_warning")
l10n_in_is_gst_registered_enabled = fields.Boolean(related='company_id.l10n_in_is_gst_registered')
l10n_in_tds_deduction = fields.Selection(related='commercial_partner_id.l10n_in_pan_entity_id.tds_deduction', string="TDS Deduction")
@api.depends('amount_total')
def _compute_amount_total_words(self):
for invoice in self:
invoice.amount_total_words = invoice.currency_id.amount_to_text(invoice.amount_total)
# withholding related fields
l10n_in_is_withholding = fields.Boolean(
string="Is Indian TDS Entry",
copy=False,
help="Technical field to identify Indian withholding entry"
)
l10n_in_withholding_ref_move_id = fields.Many2one(
comodel_name='account.move',
string="Indian TDS Ref Move",
readonly=True,
index='btree_not_null',
copy=False,
help="Reference move for withholding entry",
)
l10n_in_withholding_ref_payment_id = fields.Many2one(
comodel_name='account.payment',
string="Indian TDS Ref Payment",
index='btree_not_null',
readonly=True,
copy=False,
help="Reference Payment for withholding entry",
)
l10n_in_withhold_move_ids = fields.One2many(
'account.move', 'l10n_in_withholding_ref_move_id',
string="Indian TDS Entries"
)
l10n_in_withholding_line_ids = fields.One2many(
'account.move.line', 'move_id',
string="Indian TDS Lines",
compute='_compute_l10n_in_withholding_line_ids',
)
l10n_in_total_withholding_amount = fields.Monetary(
string="Total Indian TDS Amount",
compute='_compute_l10n_in_total_withholding_amount',
help="Total withholding amount for the move",
)
l10n_in_display_higher_tcs_button = fields.Boolean(string="Display higher TCS button", compute="_compute_l10n_in_display_higher_tcs_button")
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')
# gstin_status related field
l10n_in_partner_gstin_status = fields.Boolean(
string="GST Status",
compute="_compute_l10n_in_partner_gstin_status_and_date",
)
l10n_in_show_gstin_status = fields.Boolean(compute="_compute_l10n_in_show_gstin_status")
l10n_in_gstin_verified_date = fields.Date(compute="_compute_l10n_in_partner_gstin_status_and_date")
# -------------------------------------------------------------------------
# COMPUTE METHODS
# -------------------------------------------------------------------------
@api.depends('partner_id')
def _compute_l10n_in_gst_treatment(self):
indian_invoice = self.filtered(lambda m: m.country_code == 'IN')
for record in indian_invoice:
gst_treatment = record.partner_id.l10n_in_gst_treatment
if not gst_treatment:
gst_treatment = 'unregistered'
if record.partner_id.country_id.code == 'IN' and record.partner_id.vat:
gst_treatment = 'regular'
elif record.partner_id.country_id and record.partner_id.country_id.code != 'IN':
gst_treatment = 'overseas'
record.l10n_in_gst_treatment = gst_treatment
(self - indian_invoice).l10n_in_gst_treatment = False
for invoice in self.filtered(lambda m: m.country_code == 'IN' and m.state == 'draft'):
partner = invoice.partner_id
invoice.l10n_in_gst_treatment = (
partner.l10n_in_gst_treatment
or (
'overseas' if partner.country_id and partner.country_id.code != 'IN'
else partner.check_vat_in(partner.vat) and 'regular' or 'consumer'
)
)
@api.depends('partner_id', 'company_id')
@api.depends('partner_id', 'partner_shipping_id', 'company_id')
def _compute_l10n_in_state_id(self):
foreign_state = self.env.ref('l10n_in.state_in_oc', raise_if_not_found=False)
for move in self:
if move.country_code == 'IN' and move.journal_id.type == 'sale':
country_code = move.partner_id.country_id.code
if country_code == 'IN':
move.l10n_in_state_id = move.partner_id.state_id
elif country_code:
move.l10n_in_state_id = self.env.ref('l10n_in.state_in_oc', raise_if_not_found=False)
else:
move.l10n_in_state_id = move.company_id.state_id
if move.country_code == 'IN' and move.is_sale_document(include_receipts=True):
partner = (
move.partner_id.commercial_partner_id == move.partner_shipping_id.commercial_partner_id
and move.partner_shipping_id
or move.partner_id
)
if partner.country_id and partner.country_id.code != 'IN':
move.l10n_in_state_id = foreign_state
continue
partner_state = partner.state_id or move.partner_id.commercial_partner_id.state_id or move.company_id.state_id
country_code = partner_state.country_id.code or move.country_code
move.l10n_in_state_id = partner_state if country_code == 'IN' else foreign_state
elif move.country_code == 'IN' and move.journal_id.type == 'purchase':
move.l10n_in_state_id = move.company_id.state_id
else:
move.l10n_in_state_id = False
@api.depends('l10n_in_state_id', 'l10n_in_gst_treatment')
def _compute_fiscal_position_id(self):
foreign_state = self.env['res.country.state'].search([('code', '!=', 'IN')], limit=1)
def _get_fiscal_state(move):
"""
Maps each move to its corresponding fiscal state based on its type,
fiscal conditions, and the state of the associated partner or company.
"""
if (
move.country_code != 'IN'
or not move.is_invoice(include_receipts=True)
# Partner's FP takes precedence through super
or move.partner_shipping_id.property_account_position_id
or move.partner_id.property_account_position_id
):
return False
elif move.l10n_in_gst_treatment == 'special_economic_zone':
# Special Economic Zone
return self.env.ref('l10n_in.state_in_oc')
elif move.is_sale_document(include_receipts=True):
# In Sales Documents: Compare place of supply with company state
return move.l10n_in_state_id if move.l10n_in_state_id.l10n_in_tin != '96' else foreign_state
elif move.is_purchase_document(include_receipts=True) and move.partner_id.country_id.code == 'IN':
# In Purchases Documents: Compare place of supply with vendor state
pos_state_id = move.l10n_in_state_id
if pos_state_id.l10n_in_tin == '96':
return pos_state_id
elif pos_state_id == move.partner_id.state_id:
# Intra-State: Group by state matching the company's state.
return move.company_id.state_id
elif pos_state_id != move.partner_id.state_id:
# Inter-State: Group by state that doesn't match the company's state.
return (
pos_state_id == move.company_id.state_id
and move.partner_id.state_id
or pos_state_id
)
return False
FiscalPosition = self.env['account.fiscal.position']
for state_id, moves in self.grouped(_get_fiscal_state).items():
if state_id:
virtual_partner = self.env['res.partner'].new({
'state_id': state_id.id,
'country_id': state_id.country_id.id,
})
# Group moves by company to avoid multi-company conflicts
for company_id, company_moves in moves.grouped('company_id').items():
company_moves.fiscal_position_id = FiscalPosition.with_company(
company_id
)._get_fiscal_position(virtual_partner)
else:
super(AccountMove, moves)._compute_fiscal_position_id()
@api.onchange('name')
def _onchange_name_warning(self):
if (
self.country_code == 'IN'
and self.company_id.l10n_in_is_gst_registered
and self.journal_id.type == 'sale'
and self.name
and (len(self.name) > 16 or not re.match(r'^[a-zA-Z0-9-\/]+$', self.name))
):
return {'warning': {
'title' : _("Invalid sequence as per GST rule 46(b)"),
'message': _(
"The invoice number should not exceed 16 characters\n"
"and must only contain '-' (hyphen) and '/' (slash) as special characters"
)
}}
return super()._onchange_name_warning()
@api.depends(
'invoice_line_ids.l10n_in_hsn_code',
'company_id.l10n_in_hsn_code_digit',
'invoice_line_ids.tax_ids',
'commercial_partner_id.l10n_in_pan_entity_id',
'invoice_line_ids.price_total'
)
def _compute_l10n_in_warning(self):
indian_invoice = self.filtered(lambda m: m.country_code == 'IN' and m.move_type != 'entry')
line_filter_func = lambda line: line.display_type == 'product' and line.tax_ids and line._origin
_xmlid_to_res_id = self.env['ir.model.data']._xmlid_to_res_id
for move in indian_invoice:
warnings = {}
company = move.company_id
action_name = _("Journal Item(s)")
action_text = _("View Journal Item(s)")
if company.l10n_in_tcs_feature or company.l10n_in_tds_feature:
invalid_tax_lines = move._get_l10n_in_invalid_tax_lines()
if company.l10n_in_tcs_feature and invalid_tax_lines:
warnings['lower_tcs_tax'] = {
'message': _("As the Partner's PAN missing/invalid apply TCS at the higher rate."),
'actions': invalid_tax_lines.with_context(tax_validation=True)._get_records_action(
name=action_name,
views=[(_xmlid_to_res_id("l10n_in.view_move_line_tree_hsn_l10n_in"), "list")],
domain=[('id', 'in', invalid_tax_lines.ids)],
),
'action_text': action_text,
}
if applicable_sections := move._get_l10n_in_tds_tcs_applicable_sections():
tds_tcs_applicable_lines = (
move.move_type == 'out_invoice'
and move._get_tcs_applicable_lines(move.invoice_line_ids)
or move.invoice_line_ids
)
warnings['tds_tcs_threshold_alert'] = {
'message': applicable_sections._get_warning_message(),
'action': tds_tcs_applicable_lines.with_context(
default_tax_type_use=True,
move_type=move.move_type == 'in_invoice'
)._get_records_action(
name=action_name,
domain=[('id', 'in', tds_tcs_applicable_lines.ids)],
views=[(_xmlid_to_res_id("l10n_in.view_move_line_list_l10n_in_withholding"), "list")]
),
'action_text': action_text,
}
if (
company.l10n_in_is_gst_registered
and company.l10n_in_hsn_code_digit
and (filtered_lines := move.invoice_line_ids.filtered(line_filter_func))
):
lines = self.env['account.move.line']
for line in filtered_lines:
hsn_code = line.l10n_in_hsn_code
if (
not hsn_code
or (
not re.match(r'^\d{4}$|^\d{6}$|^\d{8}$', hsn_code)
or len(hsn_code) < int(company.l10n_in_hsn_code_digit)
)
):
lines |= line._origin
if lines:
digit_suffixes = {
'4': _("4 digits, 6 digits or 8 digits"),
'6': _("6 digits or 8 digits"),
'8': _("8 digits")
}
msg = _(
"Ensure that the HSN/SAC Code consists either %s in invoice lines",
digit_suffixes.get(company.l10n_in_hsn_code_digit, _("Invalid HSN/SAC Code digit"))
)
warnings['invalid_hsn_code_length'] = {
'message': msg,
'action': lines._get_records_action(
name=action_name,
views=[(_xmlid_to_res_id("l10n_in.view_move_line_tree_hsn_l10n_in"), "list")],
domain=[('id', 'in', lines.ids)]
),
'action_text': action_text,
}
move.l10n_in_warning = warnings
(self - indian_invoice).l10n_in_warning = {}
@api.depends('partner_id', 'state', 'payment_state', 'l10n_in_gst_treatment')
def _compute_l10n_in_show_gstin_status(self):
indian_moves = self.filtered(
lambda m: m.country_code == 'IN' and m.company_id.l10n_in_gstin_status_feature
)
(self - indian_moves).l10n_in_show_gstin_status = False
for move in indian_moves:
move.l10n_in_show_gstin_status = (
move.partner_id
and move.state == 'posted'
and move.move_type != 'entry'
and move.payment_state not in ['paid', 'reversed']
and move.l10n_in_gst_treatment in [
'regular',
'composition',
'special_economic_zone',
'deemed_export',
'uin_holders'
]
)
@api.depends('partner_id')
def _compute_l10n_in_partner_gstin_status_and_date(self):
for move in self:
if (
move.country_code == 'IN'
and move.company_id.l10n_in_gstin_status_feature
and move.payment_state not in ['paid', 'reversed']
and move.state != 'cancel'
):
move.l10n_in_partner_gstin_status = move.partner_id.l10n_in_gstin_verified_status
move.l10n_in_gstin_verified_date = move.partner_id.l10n_in_gstin_verified_date
else:
move.l10n_in_partner_gstin_status = False
move.l10n_in_gstin_verified_date = False
@api.depends('line_ids', 'l10n_in_is_withholding')
def _compute_l10n_in_withholding_line_ids(self):
# Compute the withholding lines for the move
for move in self:
if move.l10n_in_is_withholding:
move.l10n_in_withholding_line_ids = move.line_ids.filtered('tax_ids')
else:
move.l10n_in_withholding_line_ids = False
def _compute_l10n_in_total_withholding_amount(self):
for move in self:
if self.env.company.l10n_in_tds_feature:
move.l10n_in_total_withholding_amount = sum(
move.l10n_in_withhold_move_ids.filtered(
lambda m: m.state == 'posted'
).l10n_in_withholding_line_ids.mapped('l10n_in_withhold_tax_amount')
)
else:
move.l10n_in_total_withholding_amount = 0.0
@api.depends('l10n_in_warning')
def _compute_l10n_in_display_higher_tcs_button(self):
for move in self:
if move.company_id.l10n_in_tcs_feature:
move.l10n_in_display_higher_tcs_button = (
move.l10n_in_warning
and move.l10n_in_warning.get('lower_tcs_tax')
)
else:
move.l10n_in_display_higher_tcs_button = False
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)],
}
def action_l10n_in_apply_higher_tax(self):
self.ensure_one()
invalid_lines = self._get_l10n_in_invalid_tax_lines()
for line in invalid_lines:
updated_tax_ids = []
for tax in line.tax_ids:
if tax.l10n_in_tax_type == 'tcs':
max_tax = max(
tax.l10n_in_section_id.l10n_in_section_tax_ids,
key=lambda t: t.amount
)
updated_tax_ids.append(max_tax.id)
else:
updated_tax_ids.append(tax.id)
if set(line.tax_ids.ids) != set(updated_tax_ids):
line.write({'tax_ids': [Command.clear()] + [Command.set(updated_tax_ids)]})
def _get_l10n_in_invalid_tax_lines(self):
self.ensure_one()
if self.country_code == 'IN' and not self.commercial_partner_id.l10n_in_pan_entity_id:
lines = self.env['account.move.line']
for line in self.invoice_line_ids:
for tax in line.tax_ids:
if (
tax.l10n_in_tax_type == 'tcs'
and tax.amount != max(tax.l10n_in_section_id.l10n_in_section_tax_ids, key=lambda t: abs(t.amount)).amount
):
lines |= line._origin
return lines
def _get_sections_aggregate_sum_by_pan(self, section_alert, commercial_partner_id):
self.ensure_one()
month_start_date, month_end_date = get_month(self.date)
company_fiscalyear_dates = self.company_id.sudo().compute_fiscalyear_dates(self.date)
fiscalyear_start_date, fiscalyear_end_date = company_fiscalyear_dates['date_from'], company_fiscalyear_dates['date_to']
default_domain = [
('account_id.l10n_in_tds_tcs_section_id', '=', section_alert.id),
('move_id.move_type', '!=', 'entry'),
('company_id.l10n_in_tds_feature', '!=', False),
('company_id.l10n_in_tan', '=', self.company_id.l10n_in_tan),
('parent_state', '=', 'posted')
]
if commercial_partner_id.l10n_in_pan_entity_id:
default_domain += [('move_id.commercial_partner_id.l10n_in_pan_entity_id', '=', commercial_partner_id.l10n_in_pan_entity_id.id)]
else:
default_domain += [('move_id.commercial_partner_id', '=', commercial_partner_id.id)]
frequency_domains = {
'monthly': [('date', '>=', month_start_date), ('date', '<=', month_end_date)],
'fiscal_yearly': [('date', '>=', fiscalyear_start_date), ('date', '<=', fiscalyear_end_date)],
}
aggregate_result = {}
for frequency, frequency_domain in frequency_domains.items():
query = self.env['account.move.line']._search(default_domain + frequency_domain, bypass_access=True, active_test=False)
result = self.env.execute_query_dict(SQL(
"""
SELECT COALESCE(sum(account_move_line.balance), 0) as balance,
COALESCE(sum(account_move_line.price_total * am.invoice_currency_rate), 0) as price_total
FROM %s
JOIN account_move AS am ON am.id = account_move_line.move_id
WHERE %s
""",
query.from_clause,
query.where_clause)
)
aggregate_result[frequency] = result[0]
return aggregate_result
def _l10n_in_is_warning_applicable(self, section_id):
self.ensure_one()
match section_id.tax_source_type:
case 'tcs':
return self.company_id.l10n_in_tcs_feature and self.journal_id.type == 'sale'
case 'tds':
return (
self.company_id.l10n_in_tds_feature
and self.journal_id.type == 'purchase'
and section_id not in self.l10n_in_withhold_move_ids.filtered(lambda m:
m.state == 'posted'
).mapped('line_ids.tax_ids.l10n_in_section_id')
)
case _:
return False
def _get_l10n_in_tds_tcs_applicable_sections(self):
def _group_by_section_alert(invoice_lines):
group_by_lines = {}
for line in invoice_lines:
group_key = line.account_id.sudo().l10n_in_tds_tcs_section_id
if group_key and not line.company_currency_id.is_zero(line.price_total):
group_by_lines.setdefault(group_key, [])
group_by_lines[group_key].append(line)
return group_by_lines
def _is_section_applicable(section_alert, threshold_sums, invoice_currency_rate, lines):
lines_total = sum(
(line.price_total * invoice_currency_rate) if section_alert.consider_amount == 'total_amount' else line.balance
for line in lines
)
if section_alert.is_aggregate_limit:
aggregate_period_key = section_alert.consider_amount == 'total_amount' and 'price_total' or 'balance'
aggregate_total = threshold_sums.get(section_alert.aggregate_period, {}).get(aggregate_period_key)
if self.state == 'draft':
aggregate_total += lines_total
if aggregate_total > section_alert.aggregate_limit:
return True
return (
section_alert.is_per_transaction_limit
and lines_total > section_alert.per_transaction_limit
)
if self.country_code == 'IN' and self.move_type in ['in_invoice', 'out_invoice']:
warning = set()
commercial_partner_id = self.commercial_partner_id
if commercial_partner_id.l10n_in_pan_entity_id.tds_deduction == 'no':
invoice_lines = self.invoice_line_ids.filtered(lambda l: l.account_id.l10n_in_tds_tcs_section_id.tax_source_type != 'tds')
else:
invoice_lines = self.invoice_line_ids
existing_section = (
self.l10n_in_withhold_move_ids.line_ids + self.line_ids
).tax_ids.l10n_in_section_id
for section_alert, lines in _group_by_section_alert(invoice_lines).items():
if (
(section_alert not in existing_section
or self._get_tcs_applicable_lines(lines))
and self._l10n_in_is_warning_applicable(section_alert)
and _is_section_applicable(
section_alert,
self._get_sections_aggregate_sum_by_pan(
section_alert,
commercial_partner_id
),
self.invoice_currency_rate,
lines
)
):
warning.add(section_alert.id)
return self.env['l10n_in.section.alert'].browse(warning)
def _get_tcs_applicable_lines(self, lines):
tcs_applicable_lines = set()
for line in lines:
if line.l10n_in_tds_tcs_section_id not in line.tax_ids.l10n_in_section_id:
tcs_applicable_lines.add(line.id)
return self.env['account.move.line'].browse(tcs_applicable_lines)
def l10n_in_verify_partner_gstin_status(self):
self.ensure_one()
return self.with_company(self.company_id).partner_id.action_l10n_in_verify_gstin_status()
def _get_name_invoice_report(self):
self.ensure_one()
if self.country_code == 'IN':
# TODO: remove the view mode check in master, only for stable releases
in_invoice_view = self.env.ref('l10n_in.l10n_in_report_invoice_document_inherit', raise_if_not_found=False)
if (in_invoice_view and in_invoice_view.sudo().mode == "primary"):
return 'l10n_in.l10n_in_report_invoice_document_inherit'
return 'l10n_in.l10n_in_report_invoice_document_inherit'
return super()._get_name_invoice_report()
def _post(self, soft=True):
@ -80,14 +555,12 @@ class AccountMove(models.Model):
posted = super()._post(soft)
gst_treatment_name_mapping = {k: v for k, v in
self._fields['l10n_in_gst_treatment']._description_selection(self.env)}
for move in posted.filtered(lambda m: m.country_code == 'IN' and m.is_sale_document()):
"""Check state is set in company/sub-unit"""
company_unit_partner = move.journal_id.l10n_in_gstin_partner_id or move.journal_id.company_id
for move in posted.filtered(lambda m: m.country_code == 'IN' and m.company_id.l10n_in_is_gst_registered and m.is_sale_document()):
if move.l10n_in_state_id and not move.l10n_in_state_id.l10n_in_tin:
raise UserError(_("Please set a valid TIN Number on the Place of Supply %s", move.l10n_in_state_id.name))
if not company_unit_partner.state_id:
if not move.company_id.state_id:
msg = _("Your company %s needs to have a correct address in order to validate this invoice.\n"
"Set the address of your company (Don't forget the State field)") % (company_unit_partner.name)
"Set the address of your company (Don't forget the State field)", move.company_id.name)
action = {
"view_mode": "form",
"res_model": "res.company",
@ -96,9 +569,16 @@ class AccountMove(models.Model):
"views": [[self.env.ref("base.view_company_form").id, "form"]],
}
raise RedirectWarning(msg, action, _('Go to Company configuration'))
move.l10n_in_gstin = move.partner_id.vat
if not move.l10n_in_gstin and move.l10n_in_gst_treatment in ['regular', 'composition', 'special_economic_zone', 'deemed_export']:
if (
not move.l10n_in_gstin
and move.l10n_in_gst_treatment in [
'regular',
'composition',
'special_economic_zone',
'deemed_export'
]
):
raise ValidationError(_(
"Partner %(partner_name)s (%(partner_id)s) GSTIN is required under GST Treatment %(name)s",
partner_name=move.partner_id.name,
@ -113,30 +593,163 @@ class AccountMove(models.Model):
self.ensure_one()
return False
@api.ondelete(at_uninstall=False)
def _unlink_l10n_in_except_once_post(self):
# Prevent deleting entries once it's posted for Indian Company only
if any(m.country_code == 'IN' and m.posted_before for m in self) and not self._context.get('force_delete'):
raise UserError(_("To keep the audit trail, you can not delete journal entries once they have been posted.\nInstead, you can cancel the journal entry."))
def _can_be_unlinked(self):
self.ensure_one()
return (self.country_code != 'IN' or not self.posted_before) and super()._can_be_unlinked()
def unlink(self):
# Add logger here becouse in api ondelete account.move.line is deleted and we can't get total amount
logger_msg = False
if any(m.country_code == 'IN' and m.posted_before for m in self):
if self._context.get('force_delete'):
moves_details = ", ".join("{entry_number} ({move_id}) amount {amount_total} {currency} and partner {partner_name}".format(
entry_number=m.name,
move_id=m.id,
amount_total=m.amount_total,
currency=m.currency_id.name,
partner_name=m.partner_id.display_name)
for m in self)
logger_msg = 'Force deleted Journal Entries %s by %s (%s)' % (moves_details, self.env.user.name, self.env.user.id)
res = super().unlink()
if logger_msg:
_logger.info(logger_msg)
return res
def _generate_qr_code(self, silent_errors=False):
self.ensure_one()
if self.company_id.country_code == 'IN' and self.company_id.l10n_in_upi_id:
payment_url = 'upi://pay?pa=%s&pn=%s&am=%s&tr=%s&tn=%s' % (
self.company_id.l10n_in_upi_id,
self.company_id.name,
self.amount_residual,
self.payment_reference or self.name,
("Payment for %s" % self.name))
barcode = self.env['ir.actions.report'].barcode(barcode_type="QR", value=payment_url, width=120, height=120, quiet=False)
return image_data_uri(base64.b64encode(barcode))
return super()._generate_qr_code(silent_errors)
def _l10n_in_get_hsn_summary_table(self):
self.ensure_one()
base_lines, _tax_lines = self._get_rounded_base_and_tax_lines()
display_uom = self.env.user.has_group('uom.group_uom')
return self.env['account.tax']._l10n_in_get_hsn_summary_table(base_lines, display_uom)
def _l10n_in_get_bill_from_irn(self, irn):
# TO OVERRIDE
return False
# ------Utils------
@api.model
def _l10n_in_prepare_tax_details(self):
def l10n_in_grouping_key_generator(base_line, tax_data):
invl = base_line['record']
tax = tax_data['tax']
if self.l10n_in_gst_treatment in ('overseas', 'special_economic_zone') and all(
self.env.ref("l10n_in.tax_tag_igst") in rl.tag_ids
for rl in tax.invoice_repartition_line_ids if rl.repartition_type == 'tax'
):
tax_data['is_reverse_charge'] = False
tag_ids = tax.invoice_repartition_line_ids.tag_ids.ids
line_code = "other"
xmlid_to_res_id = self.env['ir.model.data']._xmlid_to_res_id
if not invl.currency_id.is_zero(tax_data['tax_amount_currency']):
if xmlid_to_res_id("l10n_in.tax_tag_cess") in tag_ids:
if tax.amount_type != "percent":
line_code = "cess_non_advol"
else:
line_code = "cess"
elif xmlid_to_res_id("l10n_in.tax_tag_state_cess") in tag_ids:
if tax.amount_type != "percent":
line_code = "state_cess_non_advol"
else:
line_code = "state_cess"
else:
for gst in ["cgst", "sgst", "igst"]:
if xmlid_to_res_id("l10n_in.tax_tag_%s" % (gst)) in tag_ids:
# need to separate rc tax value so it's not pass to other values
line_code = f'{gst}_rc' if tax_data['is_reverse_charge'] else 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):
return base_line['record'].display_type != 'rounding'
return self._prepare_invoice_aggregated_taxes(
filter_tax_values_to_apply=l10n_in_filter_to_apply,
grouping_key_generator=l10n_in_grouping_key_generator,
)
def _get_l10n_in_seller_buyer_party(self):
self.ensure_one()
return {
"seller_details": self.company_id.partner_id,
"dispatch_details": self._l10n_in_get_warehouse_address() or self.company_id.partner_id,
"buyer_details": self.partner_id,
"ship_to_details": self.partner_shipping_id or self.partner_id
}
@api.model
def _l10n_in_extract_digits(self, string):
if not string:
return ""
matches = re.findall(r"\d+", string)
return "".join(matches)
@api.model
def _l10n_in_is_service_hsn(self, hsn_code):
return self._l10n_in_extract_digits(hsn_code).startswith('99')
@api.model
def _l10n_in_round_value(self, amount, precision_digits=2):
"""
This method is call for rounding.
If anything is wrong with rounding then we quick fix in method
"""
return json_float_round(amount, precision_digits)
@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)
line_code = tax_detail["line_code"]
l10n_in_tax_details.setdefault("%s_rate" % (line_code), tax_detail["tax"].amount)
l10n_in_tax_details.setdefault("%s_amount" % (line_code), 0.00)
l10n_in_tax_details.setdefault("%s_amount_currency" % (line_code), 0.00)
l10n_in_tax_details["%s_amount" % (line_code)] += tax_detail["tax_amount"]
l10n_in_tax_details["%s_amount_currency" % (line_code)] += tax_detail["tax_amount_currency"]
return l10n_in_tax_details
@api.model
def _l10n_in_edi_get_iap_buy_credits_message(self):
url = self.env['iap.account'].get_credits_url(service_name=IAP_SERVICE_NAME)
return Markup("""<p><b>%s</b></p><p>%s <a href="%s">%s</a></p>""") % (
_("You have insufficient credits to send this document!"),
_("Please buy more credits and retry: "),
url,
_("Buy Credits")
)
def _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'

View file

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

View file

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

View file

@ -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)],
}

View file

@ -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()
],
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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("<div>%s</div>") % (title)
for value in tracking_value_ids:
audit_log_preview += Markup(
"<li>%(old_value)s <i class='o_TrackingValue_separator fa fa-long-arrow-right mx-1 text-600' title='%(title)s' role='img' aria-label='%(title)s'></i>%(new_value)s (%(field)s)</li>"
) % {
'old_value': value._get_old_display_value()[0] or _("None"),
'new_value': value._get_new_display_value()[0] or _("None"),
'title': _("Changed"),
'field': value.field.field_description,
}
message.l10n_in_audit_log_preview = audit_log_preview
@api.depends('model', 'res_id')
def _compute_l10n_in_audit_log_document_name(self):
messages_of_account_move = self.filtered(lambda m: m.model == 'account.move' and m.res_id)
(self - messages_of_account_move).l10n_in_audit_log_account_move_id = False
moves = self.env['account.move'].search([('id', 'in', messages_of_account_move.mapped('res_id'))])
moves_by_id = {m.id: m for m in moves}
for message in messages_of_account_move:
message.l10n_in_audit_log_account_move_id = moves_by_id.get(message.res_id, False)
def _search_l10n_in_audit_log_document_name(self, operator, value):
is_set = False
if operator == '!=' and isinstance(value, bool):
is_set = True
elif operator not in ['=', 'ilike'] or not isinstance(value, str):
raise UserError(_('Operation not supported'))
move_domain = [('company_id.account_fiscal_country_id.code', '=', 'IN')]
if not is_set:
move_domain += [('name', operator, value)]
move_query = self.env['account.move']._search(move_domain)
return [('model', '=', 'account.move'), ('res_id', 'in', move_query)]

View file

@ -1,10 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class L10nInPortCode(models.Model):
class L10n_InPortCode(models.Model):
"""Port code must be mentioned in export and import of goods under GST."""
_name = 'l10n_in.port.code'
_description = "Indian port code"
@ -14,6 +11,7 @@ class L10nInPortCode(models.Model):
name = fields.Char(string="Port", required=True)
state_id = fields.Many2one('res.country.state', string="State")
_sql_constraints = [
('code_uniq', 'unique (code)', 'The Port Code must be unique!')
]
_code_uniq = models.Constraint(
'unique (code)',
'The Port Code must be unique!',
)

View file

@ -1,11 +1,41 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
import re
from odoo import _, api, models, fields
class ProductTemplate(models.Model):
_inherit = 'product.template'
l10n_in_hsn_code = fields.Char(string="HSN/SAC Code", help="Harmonized System Nomenclature/Services Accounting Code")
l10n_in_hsn_description = fields.Char(string="HSN/SAC Description", help="HSN/SAC description is required if HSN/SAC code is not provided.")
l10n_in_hsn_warning = fields.Text(string="HSC/SAC warning", compute="_compute_l10n_in_hsn_warning")
l10n_in_is_gst_registered_enabled = fields.Boolean(compute="_compute_l10n_in_is_gst_registered_enabled")
@api.depends('company_id.l10n_in_is_gst_registered')
@api.depends_context('allowed_company_ids')
def _compute_l10n_in_is_gst_registered_enabled(self):
for record in self:
allowed_companies = record.company_id or self.env.companies
record.l10n_in_is_gst_registered_enabled = any(
company.l10n_in_is_gst_registered
for company in allowed_companies
)
@api.depends('sale_ok', 'l10n_in_hsn_code')
def _compute_l10n_in_hsn_warning(self):
digit_suffixes = {
'4': _("either 4, 6 or 8"),
'6': _("either 6 or 8"),
'8': _("8")
}
active_hsn_code_digit_len = max(
int(company.l10n_in_hsn_code_digit)
for company in self.env.companies
)
for record in self:
check_hsn = record.sale_ok and record.l10n_in_hsn_code and active_hsn_code_digit_len
if check_hsn and (not re.match(r'^\d{4}$|^\d{6}$|^\d{8}$', record.l10n_in_hsn_code) or len(record.l10n_in_hsn_code) < active_hsn_code_digit_len):
record.l10n_in_hsn_warning = _(
"HSN code field must consist solely of digits and be %s in length.",
digit_suffixes.get(str(active_hsn_code_digit_len))
)
continue
record.l10n_in_hsn_warning = False

View file

@ -1,10 +1,190 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, fields, models
from odoo.exceptions import UserError, ValidationError, RedirectWarning
from odoo.tools.sql import column_exists, create_column
from odoo.tools import SQL
from odoo import api, fields, models
from odoo.addons.l10n_in.models.iap_account import IAP_SERVICE_NAME
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
group_l10n_in_reseller = fields.Boolean(implied_group='l10n_in.group_l10n_in_reseller', string="Manage Reseller(E-Commerce)")
group_l10n_in_reseller = fields.Boolean(
implied_group='l10n_in.group_l10n_in_reseller',
string="Manage Reseller(E-Commerce)"
)
l10n_in_edi_production_env = fields.Boolean(
string="Indian Production Environment",
related="company_id.l10n_in_edi_production_env",
readonly=False
)
l10n_in_gsp = fields.Selection(selection=[
('bvm', 'BVM IT Consulting'),
('tera', 'Tera Software (Deprecated)'),
], string="GSP",
inverse="_set_l10n_in_gsp", # use an inverse method to invalidate existing tokens if the GSP is changed
store=False,
help="Select the GST Suvidha Provider (GSP) you want to use for GST services.",
)
l10n_in_hsn_code_digit = fields.Selection(
related='company_id.l10n_in_hsn_code_digit',
readonly=False
)
# TDS/TCS settings
l10n_in_tds_feature = fields.Boolean(
related='company_id.l10n_in_tds_feature',
readonly=False
)
l10n_in_tcs_feature = fields.Boolean(
related='company_id.l10n_in_tcs_feature',
readonly=False
)
l10n_in_withholding_account_id = fields.Many2one(
related='company_id.l10n_in_withholding_account_id',
readonly=False
)
l10n_in_withholding_journal_id = fields.Many2one(
related='company_id.l10n_in_withholding_journal_id',
readonly=False
)
l10n_in_tan = fields.Char(
related='company_id.l10n_in_tan',
readonly=False
)
# GST settings
l10n_in_is_gst_registered = fields.Boolean(
related='company_id.l10n_in_is_gst_registered',
readonly=False
)
l10n_in_gstin = fields.Char(
string="GST Number",
related='company_id.vat',
readonly=False
)
l10n_in_gstin_status_feature = fields.Boolean(
related='company_id.l10n_in_gstin_status_feature',
readonly=False
)
l10n_in_gst_efiling_feature = fields.Boolean(string="GST E-Filing & Matching Feature")
l10n_in_fetch_vendor_edi_feature = fields.Boolean(string="Fetch Vendor E-Invoiced Document")
l10n_in_enet_vendor_batch_payment_feature = fields.Boolean(string="ENet Vendor Batch Payment")
module_l10n_in_reports = fields.Boolean("GST E-Filing & Matching")
module_l10n_in_edi = fields.Boolean("Indian Electronic Invoicing")
module_l10n_in_ewaybill = fields.Boolean("Indian Electronic Waybill")
def set_values(self):
super().set_values()
if self.country_code == 'IN':
if (
not self.module_l10n_in_reports
and (
self.l10n_in_fetch_vendor_edi_feature
or self.l10n_in_gst_efiling_feature
or self.l10n_in_enet_vendor_batch_payment_feature
)
):
self.module_l10n_in_reports = True
for l10n_in_feature in (
"l10n_in_fetch_vendor_edi_feature",
"l10n_in_gst_efiling_feature",
"l10n_in_enet_vendor_batch_payment_feature",
):
if self[l10n_in_feature]:
self._update_l10n_in_feature(l10n_in_feature)
if self.module_l10n_in_edi:
self._update_l10n_in_feature("l10n_in_edi_feature")
if self.module_l10n_in_ewaybill:
self._update_l10n_in_feature("l10n_in_ewaybill_feature")
def _update_l10n_in_feature(self, column):
""" This way, after installing the module, the field will already be set for the active company. """
if not column_exists(self.env.cr, "res_company", column):
create_column(self.env.cr, "res_company", column, "boolean")
self.env.cr.execute(SQL(
f"""
UPDATE res_company
SET {column} = true
WHERE id = {self.env.company.id}
"""
))
def l10n_in_edi_buy_iap(self):
if (
not self.l10n_in_edi_production_env
or not (
self.module_l10n_in_edi
or self.module_l10n_in_ewaybill
or self.l10n_in_gstin_status_feature
or self.l10n_in_gst_efiling_feature
)
):
raise ValidationError(_(
"Please ensure that at least one Indian service and production environment is enabled,"
" and save the configuration to proceed with purchasing credits."
))
return {
'type': 'ir.actions.act_url',
'url': self.env["iap.account"].get_credits_url(service_name=IAP_SERVICE_NAME),
'target': '_new'
}
def _l10n_in_check_gst_number(self):
company = self.company_id
if not company.partner_id.check_vat_in(company.vat):
action = {
'view_mode': 'form',
'res_model': 'res.company',
'type': 'ir.actions.act_window',
'res_id': company.id,
'views': [[self.env.ref('base.view_company_form').id, 'form']],
}
raise RedirectWarning(_("Please set a valid GST number on company."), action, _("Go to Company"))
def reload_template(self):
super().reload_template()
if self.country_code == 'IN':
branch_companies = self.company_id.child_ids
if branch_companies:
branch_companies._update_l10n_in_fiscal_position()
def _l10n_in_is_first_time_setup(self):
"""
Check if at least one company for India has been configured with the localization settings.
If not, it means it's the first time setup.
"""
all_validity_fields = ['l10n_in_gstr_gst_token_validity', 'l10n_in_edi_token_validity', 'l10n_in_ewaybill_auth_validity']
validity_fields = (field_name for field_name in self.company_id._fields if field_name in all_validity_fields)
if validity_fields:
validity_fields_domain = fields.Domain.OR([[(field_name, '!=', False)] for field_name in validity_fields])
configured_company_count = self.env['res.company'].sudo().search_count([
('account_fiscal_country_id.code', '=', 'IN'),
*validity_fields_domain
])
return not configured_company_count
return True
def get_values(self):
res = super().get_values()
res['l10n_in_gsp'] = self.env['ir.config_parameter'].sudo().get_param('l10n_in.gsp_provider')
if not res['l10n_in_gsp']:
if self._l10n_in_is_first_time_setup():
# Default to BVM for new databases setting up India localization for the first time
res['l10n_in_gsp'] = 'bvm'
else:
res['l10n_in_gsp'] = 'tera'
return res
def _l10n_in_gsp_provider_changed(self):
""" Hook to be overridden in other modules to handle GSP provider change. """
self.ensure_one()
self.env['ir.config_parameter'].sudo().set_param('l10n_in.gsp_provider', self.l10n_in_gsp)
def _set_l10n_in_gsp(self):
gsp_before = self.env['ir.config_parameter'].sudo().get_param('l10n_in.gsp_provider')
for config in self:
if gsp_before != config.l10n_in_gsp:
config._l10n_in_gsp_provider_changed()
return

View file

@ -1,10 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class CountryState(models.Model):
class ResCountryState(models.Model):
_inherit = 'res.country.state'
l10n_in_tin = fields.Char('TIN Number', size=2, help="TIN number-first two digits")
l10n_in_tin = fields.Char("TIN Number", size=2, help="TIN number-first two digits")

View file

@ -1,9 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import re
from stdnum.in_ import pan
from odoo import api, fields, models, _
from odoo.exceptions import UserError, AccessError, ValidationError
from odoo.addons.l10n_in.models.iap_account import IAP_SERVICE_NAME
from odoo.tools.misc import clean_context
_logger = logging.getLogger(__name__)
TEST_GST_NUMBER = "36AABCT1332L011"
TEST_GST_NUMBER_BVM = "29AAGCB1286Q000"
class ResPartner(models.Model):
_inherit = 'res.partner'
@ -19,33 +27,206 @@ class ResPartner(models.Model):
('uin_holders', 'UIN Holders'),
], string="GST Treatment")
@api.onchange('company_type')
def onchange_company_type(self):
res = super().onchange_company_type()
if self.country_id and self.country_id.code == 'IN':
self.l10n_in_gst_treatment = (self.company_type == 'company') and 'regular' or 'consumer'
l10n_in_pan_entity_id = fields.Many2one(
comodel_name='l10n_in.pan.entity',
string="PAN",
ondelete='restrict',
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_tan = fields.Char("TAN")
display_pan_warning = fields.Boolean(string="Display pan warning", compute="_compute_display_pan_warning")
l10n_in_gst_state_warning = fields.Char(compute="_compute_l10n_in_gst_state_warning")
l10n_in_is_gst_registered_enabled = fields.Boolean(compute="_compute_l10n_in_gst_registered_and_status")
# gstin_status related field
l10n_in_gstin_verified_status = fields.Boolean(string="GST Status", tracking=True)
l10n_in_gstin_verified_date = fields.Date(string="GSTIN Verified Date", tracking=True)
l10n_in_gstin_status_feature_enabled = fields.Boolean(compute="_compute_l10n_in_gst_registered_and_status")
@api.depends('vat', 'state_id', 'country_id', 'fiscal_country_codes')
def _compute_l10n_in_gst_state_warning(self):
for partner in self:
if (
"IN" in partner.fiscal_country_codes
and partner.check_vat_in(partner.vat)
):
if partner.vat[:2] == "99":
partner.l10n_in_gst_state_warning = _(
"As per GSTN the country should be other than India, so it's recommended to"
)
else:
state_id = self.env['res.country.state'].search([('l10n_in_tin', '=', partner.vat[:2])], limit=1)
if state_id and state_id != partner.state_id:
partner.l10n_in_gst_state_warning = _(
"As per GSTN the state should be %s, so it's recommended to", state_id.name
)
else:
partner.l10n_in_gst_state_warning = False
else:
partner.l10n_in_gst_state_warning = False
@api.depends('l10n_in_pan_entity_id')
def _compute_display_pan_warning(self):
for partner in self:
partner.display_pan_warning = partner.vat and partner.l10n_in_pan_entity_id and partner.l10n_in_pan_entity_id.name != partner.vat[2:12]
@api.depends('company_id.l10n_in_is_gst_registered', 'company_id.l10n_in_gstin_status_feature')
def _compute_l10n_in_gst_registered_and_status(self):
for record in self:
company = record.company_id or self.env.company
record.l10n_in_is_gst_registered_enabled = company.l10n_in_is_gst_registered
record.l10n_in_gstin_status_feature_enabled = company.l10n_in_gstin_status_feature
@api.onchange('vat')
def _onchange_l10n_in_gst_status(self):
"""
Reset GST Status Whenever the `vat` of partner changes
"""
for partner in self:
if partner.country_code == 'IN' and (partner.l10n_in_gstin_verified_status or partner.l10n_in_gstin_verified_date):
partner.l10n_in_gstin_verified_status = False
partner.l10n_in_gstin_verified_date = False
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
if 'import_file' in self.env.context:
return res
for partner in res.filtered(lambda p: p.country_code == 'IN' and p.vat and p.check_vat_in(p.vat)):
partner._set_l10n_in_pan_tan_from_vat()
return res
@api.onchange('country_id')
def _onchange_country_id(self):
res = super()._onchange_country_id()
if self.country_id and self.country_id.code != 'IN':
self.l10n_in_gst_treatment = 'overseas'
elif self.country_id and self.country_id.code == 'IN':
self.l10n_in_gst_treatment = (self.company_type == 'company') and 'regular' or 'consumer'
def write(self, vals):
res = super().write(vals)
if 'import_file' in self.env.context:
return res
if vals.get('vat') or vals.get('country_id'):
for partner in self.filtered(lambda p: p.country_code == 'IN' and p.vat and p.check_vat_in(p.vat)):
partner._set_l10n_in_pan_tan_from_vat()
return res
def _set_l10n_in_pan_tan_from_vat(self):
self.ensure_one()
identifier = self.vat[2:12].upper()
if pan.is_valid(identifier):
self.l10n_in_pan_entity_id = self._l10n_in_search_create_pan_entity_from_vat(self.vat).id
elif re.match(r'^[A-Z]{4}[0-9]{5}[A-Z]{1}$', identifier):
self.l10n_in_tan = identifier
def _l10n_in_search_create_pan_entity_from_vat(self, vat):
pan_number = vat[2:12].upper()
pan_entity = self.env['l10n_in.pan.entity'].search([('name', '=', pan_number)], limit=1)
if not pan_entity:
context = clean_context(self.env.context)
pan_entity = self.env['l10n_in.pan.entity'].with_context(context).create({'name': pan_number})
return pan_entity
def action_l10n_in_verify_gstin_status(self):
self.ensure_one()
self.check_access('write')
if self.env.company.sudo().account_fiscal_country_id.code != 'IN':
raise UserError(_('You must be logged in an Indian company to use this feature'))
if not self.vat:
raise ValidationError(_("Please enter the GSTIN"))
if not self.env.company.l10n_in_gstin_status_feature:
raise ValidationError(_("This feature is not activated. Go to Settings to activate this feature."))
is_production = self.env.company.sudo().l10n_in_edi_production_env
params = {
"gstin_to_search": self.vat,
"gstin": self.env.company.vat,
}
try:
response = self.env['iap.account']._l10n_in_connect_to_server(
is_production,
params,
'/iap/l10n_in_reports/1/public/search',
"l10n_in.endpoint"
)
except AccessError:
raise UserError(_("Unable to connect with GST network"))
if response.get('error') and any(e.get('code') == 'no-credit' for e in response['error']):
return self.env["bus.bus"]._sendone(self.env.user.partner_id, "iap_notification",
{
"type": "no_credit",
"title": _("Not enough credits to check GSTIN status"),
"get_credits_url": self.env["iap.account"].get_credits_url(service_name=IAP_SERVICE_NAME),
},
)
gst_status = response.get('data', {}).get('sts', "")
if gst_status.casefold() == 'active':
l10n_in_gstin_verified_status = True
elif gst_status:
l10n_in_gstin_verified_status = False
date_from = response.get("data", {}).get("cxdt", '')
if date_from and re.search(r'\d', date_from):
message = _(
"GSTIN %(vat)s is %(status)s and Effective from %(date_from)s.",
vat=self.vat,
status=gst_status,
date_from=date_from,
)
else:
message = _(
"GSTIN %(vat)s is %(status)s, effective date is not available.",
vat=self.vat,
status=gst_status
)
if not is_production:
message += _(" Warning: You are currently in a test environment. The result is a dummy.")
self.message_post(body=message)
else:
_logger.info("GST status check error %s", response)
if response.get('error') and any(e.get('code') == 'SWEB_9035' for e in response['error']):
raise UserError(
_("The provided GSTIN is invalid. Please check the GSTIN and try again.")
)
default_error_message = _(
"Something went wrong while fetching the GST status."
"Please Contact Support if the error persists with"
"Response: %(response)s",
response=response
)
error_messages = [
f"[{error.get('code') or _('Unknown')}] {error.get('message') or default_error_message}"
for error in response.get('error')
]
raise UserError(
error_messages
and '\n'.join(error_messages)
or default_error_message
)
self.write({
"l10n_in_gstin_verified_status": l10n_in_gstin_verified_status,
"l10n_in_gstin_verified_date": fields.Date.today(),
})
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"type": "info",
"message": _("GSTIN Status Updated Successfully"),
"next": {"type": "ir.actions.act_window_close"},
},
}
@api.onchange('vat')
def onchange_vat(self):
if self.vat and self.check_vat_in(self.vat):
self.vat = self.vat.upper()
state_id = self.env['res.country.state'].search([('l10n_in_tin', '=', self.vat[:2])], limit=1)
if state_id:
self.state_id = state_id
pan_entity = self.env['l10n_in.pan.entity'].search([('name', '=', self.vat[2:12])], limit=1)
if pan_entity:
self.l10n_in_pan_entity_id = pan_entity.id
@api.model
def _commercial_fields(self):
res = super()._commercial_fields()
return res + ['l10n_in_gst_treatment']
return super()._commercial_fields() + ['l10n_in_gst_treatment', 'l10n_in_pan_entity_id', 'l10n_in_tan']
def check_vat_in(self, vat):
"""
@ -53,6 +234,28 @@ class ResPartner(models.Model):
but this is not a valid number as per the regular expression
so TEST_GST_NUMBER is considered always valid
"""
if vat == TEST_GST_NUMBER:
if vat in (TEST_GST_NUMBER, TEST_GST_NUMBER_BVM):
return True
return super().check_vat_in(vat)
@api.model
def _l10n_in_get_partner_vals_by_vat(self, vat):
partner_data = self.enrich_by_gst(vat)
for fname in list(partner_data.keys()):
if fname not in self.env['res.partner']._fields:
partner_data.pop(fname, None)
partner_data.update({
'country_id': partner_data.get('country_id', {}).get('id'),
'state_id': partner_data.get('state_id', {}).get('id'),
'company_type': 'company',
'l10n_in_gst_treatment': partner_data.get('l10n_in_gst_treatment', 'regular'),
})
return partner_data
def action_update_state_as_per_gstin(self):
self.ensure_one()
if self.check_vat_in(self.vat):
state_id = self.env['res.country.state'].search([('l10n_in_tin', '=', self.vat[:2])], limit=1)
self.state_id = state_id
if self.ref_company_ids:
self.ref_company_ids._update_l10n_in_fiscal_position()

View file

@ -0,0 +1,143 @@
from odoo import Command, models
from odoo.addons.account.models.chart_template import template
class AccountChartTemplate(models.AbstractModel):
_inherit = 'account.chart.template'
@template('in')
def _get_in_template_data(self):
return {
'property_account_receivable_id': 'p10040',
'property_account_payable_id': 'p11211',
'code_digits': '6',
'display_invoice_amount_total_words': True,
}
@template('in', 'res.company')
def _get_in_res_company(self):
return {
self.env.company.id: {
'account_fiscal_country_id': 'base.in',
'bank_account_code_prefix': '1002',
'cash_account_code_prefix': '1001',
'transfer_account_code_prefix': '1008',
'account_default_pos_receivable_account_id': 'p10041',
'income_currency_exchange_account_id': 'p2013',
'expense_currency_exchange_account_id': 'p2117',
'account_journal_early_pay_discount_loss_account_id': 'p2132',
'account_journal_early_pay_discount_gain_account_id': '2012',
'fiscalyear_last_month': '3',
'account_sale_tax_id': 'sgst_sale_5',
'account_purchase_tax_id': 'sgst_purchase_5',
'deferred_expense_account_id': 'p10084',
'deferred_revenue_account_id': 'p10085',
'expense_account_id': 'p2107',
'income_account_id': 'p20011',
'l10n_in_withholding_account_id': 'p100595',
'tax_calculation_rounding_method': 'round_per_line',
},
}
@template('in', 'account.cash.rounding')
def _get_in_account_cash_rounding(self):
return {
'l10n_in.cash_rounding_in_half_up': {
'profit_account_id': 'p213202',
'loss_account_id': 'p213201',
}
}
@template('in', 'account.fiscal.position')
def _get_in_account_fiscal_position(self):
_ = self.env._
company = self.env.company
state_ids = [Command.set(company.state_id.ids)] if company.state_id else False
intra_state_name = company.state_id and _("Within %s", company.state_id.name) or _("Intra State")
country_in_id = self.env.ref('base.in').id
state_specific = {
'fiscal_position_in_intra_state': {
'name': intra_state_name,
'sequence': 1,
'auto_apply': True,
'state_ids': state_ids,
'tax_ids': self._get_l10n_in_fiscal_tax_vals('fiscal_position_in_intra_state'),
'country_id': country_in_id,
},
'fiscal_position_in_inter_state': {
'name': _("Inter State"),
'sequence': 2,
'auto_apply': True,
'country_group_id': 'l10n_in.inter_state_group',
'tax_ids': self._get_l10n_in_fiscal_tax_vals('fiscal_position_in_inter_state'),
},
}
if company.parent_id:
return {
self.company_xmlid(k): v
for k, v in state_specific.items()
}
return {
**state_specific,
'fiscal_position_in_sez': {
'name': _("Special Economic Zone (SEZ)"),
'sequence': 3,
'auto_apply': True,
'state_ids': [Command.set(self.env.ref('l10n_in.state_in_oc').ids)],
'country_id': country_in_id,
'note': _("SUPPLY MEANT FOR EXPORT/SUPPLY TO SEZ UNIT OR SEZ DEVELOPER FOR AUTHORISED OPERATIONS ON PAYMENT OF INTEGRATED TAX."),
'tax_ids': self._get_l10n_in_fiscal_tax_vals('fiscal_position_in_inter_state'),
},
'fiscal_position_in_export_sez_in': {
'name': _("Export"),
'sequence': 4,
'auto_apply': True,
'note': _("SUPPLY MEANT FOR EXPORT/SUPPLY TO SEZ UNIT OR SEZ DEVELOPER FOR AUTHORISED OPERATIONS ON PAYMENT OF INTEGRATED TAX."),
'tax_ids': self._get_l10n_in_fiscal_tax_vals('fiscal_position_in_export_sez_in'),
},
'fiscal_position_in_lut_sez_1': {
'name': _("SEZ - LUT (WOP)"),
'sequence': 5,
'state_ids': [Command.set(self.env.ref('l10n_in.state_in_oc').ids)],
'country_id': country_in_id,
'note': _("SUPPLY MEANT FOR EXPORT/SUPPLY TO SEZ UNIT OR SEZ DEVELOPER FOR AUTHORISED OPERATIONS UNDER BOND OR LETTER OF UNDERTAKING WITHOUT PAYMENT OF INTEGRATED TAX."),
'tax_ids': self._get_l10n_in_fiscal_tax_vals('fiscal_position_in_lut_sez_1'),
},
'fiscal_position_in_lut_sez': {
'name': _("Export - LUT (WOP)"),
'sequence': 6,
'note': _('SUPPLY MEANT FOR EXPORT/SUPPLY TO SEZ UNIT OR SEZ DEVELOPER FOR AUTHORISED OPERATIONS UNDER BOND OR LETTER OF UNDERTAKING WITHOUT PAYMENT OF INTEGRATED TAX.'),
'tax_ids': self._get_l10n_in_fiscal_tax_vals('fiscal_position_in_lut_sez'),
},
}
def _get_l10n_in_fiscal_tax_vals(self, fiscal_position_xml_ids):
rates = [1, 2, 5, 12, 18, 28, 40]
taxes_xml_ids = []
if fiscal_position_xml_ids == 'fiscal_position_in_intra_state':
taxes_xml_ids = [f"sgst_{tax_type}_{rate}" for tax_type in ["sale", "purchase"] for rate in rates]
elif fiscal_position_xml_ids == 'fiscal_position_in_inter_state':
taxes_xml_ids = [f"igst_{tax_type}_{rate}" for tax_type in ["sale", "purchase"] for rate in rates]
elif fiscal_position_xml_ids == 'fiscal_position_in_export_sez_in':
taxes_xml_ids = [f"igst_sale_{rate}_sez_exp" for rate in rates] + [f"igst_purchase_{rate}" for rate in rates] + ['igst_sale_0_sez_exp']
elif fiscal_position_xml_ids == 'fiscal_position_in_lut_sez':
taxes_xml_ids = [f"igst_sale_{rate}_sez_exp_lut" for rate in rates] + ['igst_sale_0_sez_exp_lut']
elif fiscal_position_xml_ids == 'fiscal_position_in_lut_sez_1':
taxes_xml_ids = [f"igst_sale_{rate}_sez_lut" for rate in rates] + ['igst_sale_0_sez_lut']
return [Command.set(taxes_xml_ids)]
def _post_load_data(self, template_code, company, template_data):
super()._post_load_data(template_code, company, template_data)
if template_code == 'in':
company = company or self.env.company
company._update_l10n_in_is_gst_registered()
# The COA (Chart of Accounts) data is loaded after the initial compute methods are called.
# During initial journal setup, the payment methods and accounts may not exist yet,
# causing the payment method lines to not be properly configured.
# We call these helper methods again in _post_load_data to ensure all payment method lines
# are correctly assigned once all COA data is fully available.
bank_journals = company.bank_journal_ids
bank_journals._update_payment_method_lines("inbound")
bank_journals._update_payment_method_lines("outbound")

View file

@ -1,10 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class UoM(models.Model):
class UomUom(models.Model):
_inherit = "uom.uom"
# As per GST Rules you need to Specify UQC given by GST.