Skip to content

Commit 5c86ecc

Browse files
committed
[IMP] account_invoice_tax: refactor tax override to use a Json field
Replace the old relational model approach with a lightweight `tax_override_data = fields.Json(copy=False)` on `account.move`. The field stores manually-set amounts keyed by tax id (string) with the structure `{"<tax_id>": {"amount": float, "rate": float}}`. Overrides are **only** persisted for fixed-amount taxes; percentage-based taxes are always recomputed automatically and are never stored here. closes #261 Signed-off-by: rov-adhoc <rov@adhoc.com.ar>
1 parent fa5caa8 commit 5c86ecc

7 files changed

Lines changed: 256 additions & 41 deletions

File tree

account_invoice_tax/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
# Part of Odoo. See LICENSE file for full copyright and licensing details.
22
from . import wizards
3+
from . import models

account_invoice_tax/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "Account Invoice Tax",
3-
"version": "18.0.1.0.0",
3+
"version": "18.0.1.2.0",
44
"author": "ADHOC SA",
55
"category": "Localization",
66
"depends": [
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import logging
2+
from datetime import date
3+
4+
from dateutil.relativedelta import relativedelta
5+
from odoo import SUPERUSER_ID, api
6+
7+
_logger = logging.getLogger(__name__)
8+
9+
10+
def migrate(cr, version):
11+
"""Populate tax_override_data for vendor bills that have fixed-amount tax
12+
lines but whose override data was never stored.
13+
14+
Targets invoices of type in_invoice / in_refund / in_receipt created in
15+
the last month that:
16+
- have at least one tax line linked to a fixed-amount tax, and
17+
- have tax_override_data IS NULL (never set).
18+
19+
Processes up to BATCH_LIMIT records.
20+
"""
21+
env = api.Environment(cr, SUPERUSER_ID, {})
22+
23+
month_start = date.today() - relativedelta(months=3)
24+
25+
moves = env["account.move"].search(
26+
[
27+
("move_type", "in", ["in_invoice", "in_refund", "in_receipt"]),
28+
("invoice_date", ">=", month_start),
29+
("tax_override_data", "=", False),
30+
("line_ids.tax_line_id.amount_type", "=", "fixed"),
31+
],
32+
limit=1000,
33+
order="id desc",
34+
)
35+
36+
if not moves:
37+
_logger.info("account_invoice_tax migration: no invoices to process.")
38+
return
39+
40+
_logger.info("account_invoice_tax migration: processing %d invoice(s).", len(moves))
41+
moves.sync_tax_override_from_tax_totals()
42+
43+
_logger.info("account_invoice_tax migration: done.")
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import account_move
2+
from . import account_tax
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
from contextlib import contextmanager
2+
3+
from odoo import fields, models
4+
5+
6+
class AccountMove(models.Model):
7+
_inherit = "account.move"
8+
9+
# Stores manually-overridden tax amounts keyed by tax id (as string).
10+
# Structure: { "<tax_id>": {"amount": float, "rate": float} }
11+
# Only fixed-amount taxes are stored here; percentage taxes are always
12+
# recomputed automatically.
13+
tax_override_data = fields.Json()
14+
15+
def sync_tax_override_from_tax_totals(self):
16+
"""Rebuild ``tax_override_data`` from the current ``tax_totals`` widget value.
17+
18+
Iterates every tax group in ``tax_totals`` and, for each fixed-amount
19+
tax found, stores the displayed amounts as an override so they survive
20+
future recomputations.
21+
22+
``tax_totals`` structure (relevant fields)::
23+
24+
{
25+
"subtotals": [{
26+
"tax_groups": [{
27+
"involved_tax_ids": [<tax_id>, ...],
28+
"tax_amount": <float>, # company currency
29+
}, ...]
30+
}, ...]
31+
}
32+
33+
``amount`` (invoice-currency) → ``tax_amount_currency`` when the
34+
invoice is in a foreign currency, otherwise ``tax_amount``.
35+
``amount_company_currency`` → ``tax_amount`` (always company currency),
36+
or 0.0 when the invoice is in company currency (field unused in that
37+
case, see ``_apply_tax_overrides``).
38+
"""
39+
for rec in self.filtered(lambda m: m.move_type in ("in_invoice", "in_refund", "in_receipt")):
40+
tax_totals = rec.tax_totals
41+
if not tax_totals:
42+
continue
43+
44+
is_company_currency = rec.currency_id == rec.company_currency_id
45+
new_overrides = {}
46+
47+
for subtotal in tax_totals.get("subtotals", []):
48+
for tax_group in subtotal.get("tax_groups", []):
49+
tax_amount = tax_group.get("tax_amount", 0.0)
50+
tax_amount_currency = tax_group.get("tax_amount_currency", 0.0)
51+
52+
for tax_id in tax_group.get("involved_tax_ids", []):
53+
tax = rec.env["account.tax"].browse(tax_id)
54+
if not tax.exists() or tax.amount_type != "fixed":
55+
continue
56+
57+
if is_company_currency:
58+
amount = tax_amount
59+
rate = 1
60+
else:
61+
amount = tax_amount_currency
62+
rate = rec.invoice_currency_rate or 1.0
63+
64+
new_overrides[str(tax_id)] = {
65+
"amount": amount,
66+
"rate": rate,
67+
}
68+
69+
rec.tax_override_data = new_overrides or False
70+
71+
# ------------------------------------------------------------------
72+
# Tax-totals widget: inject override amounts so the widget reflects
73+
# the manually-set values instead of the recomputed ones.
74+
# ------------------------------------------------------------------
75+
76+
def _compute_tax_totals(self):
77+
for move in self:
78+
overrides = move.tax_override_data or {}
79+
if not overrides:
80+
super(AccountMove, move)._compute_tax_totals()
81+
continue
82+
83+
tax_context = {
84+
"tax_context": {
85+
int(tax_id): {
86+
"fixed_amount": (vals["amount"]),
87+
"rate": (move.invoice_currency_rate or 1.0),
88+
}
89+
for tax_id, vals in overrides.items()
90+
},
91+
}
92+
super(AccountMove, move.with_context(**tax_context))._compute_tax_totals()
93+
94+
# ------------------------------------------------------------------
95+
# Re-apply overrides after every tax-line recomputation so that the
96+
# amounts set through the wizard survive any subsequent edits on the
97+
# invoice lines.
98+
# ------------------------------------------------------------------
99+
100+
@contextmanager
101+
def _sync_tax_lines(self, container):
102+
"""Restore manually-set tax amounts after the core recomputes tax lines."""
103+
with super()._sync_tax_lines(container):
104+
yield
105+
for move in container.get("records", self):
106+
move._apply_tax_overrides()
107+
108+
def _apply_tax_overrides(self):
109+
"""Re-write values from ``tax_override_data`` onto the matching tax lines.
110+
111+
Only overrides for fixed-amount taxes are applied; percentage-based
112+
taxes must always reflect their recomputed values.
113+
"""
114+
overrides = self.tax_override_data or {}
115+
if not overrides:
116+
return
117+
118+
move_currency = self.currency_id
119+
company_currency = self.company_currency_id
120+
not_company_currency = move_currency and move_currency != company_currency
121+
for line in self.line_ids.filtered(lambda l: l.tax_line_id and str(l.tax_line_id.id) in overrides):
122+
vals = overrides[str(line.tax_line_id.id)]
123+
rate = line.move_id.invoice_currency_rate or 1.0
124+
amount = vals.get("amount", 0.0)
125+
amount_cc = amount / rate
126+
debit = credit = debit_cc = credit_cc = 0.0
127+
if self.move_type in ("in_invoice", "in_receipt"):
128+
if amount > 0:
129+
debit, debit_cc = amount, amount_cc
130+
elif amount < 0:
131+
credit, credit_cc = -amount, -amount_cc
132+
else:
133+
if amount > 0:
134+
credit, credit_cc = amount, amount_cc
135+
elif amount < 0:
136+
debit, debit_cc = -amount, -amount_cc
137+
138+
line_vals = {
139+
"debit": debit_cc if not_company_currency else debit,
140+
"credit": credit_cc if not_company_currency else credit,
141+
"balance": ((amount_cc if not_company_currency else amount) * (1 if debit or debit_cc else -1)),
142+
}
143+
if not_company_currency and amount:
144+
line_vals["amount_currency"] = amount
145+
line.write(line_vals)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from odoo import api, models
2+
3+
4+
class AccountTax(models.Model):
5+
_inherit = "account.tax"
6+
7+
@api.model
8+
def _get_tax_totals_summary(self, base_lines, currency, company, cash_rounding=None):
9+
res = super()._get_tax_totals_summary(base_lines, currency, company, cash_rounding=cash_rounding)
10+
# ``tax_context`` is injected by AccountMove._compute_tax_totals when
11+
# there are active tax overrides. Structure:
12+
# { tax_id (int): {'fixed_amount': float, 'rate': float}, ... }
13+
if tax_context := self.env.context.get("tax_context"):
14+
for tax_group in res.get("subtotals", [{}])[0].get("tax_groups", []):
15+
for involved_tax_id in tax_group.get("involved_tax_ids", []):
16+
override = tax_context.get(involved_tax_id)
17+
if not override:
18+
continue
19+
new_amount = override.get("fixed_amount", 0.0)
20+
rate = override.get("rate", 1.0)
21+
original_amount = tax_group.get("tax_amount", 0.0)
22+
original_currency_amount = tax_group.get("tax_amount_currency", 0.0)
23+
if not new_amount or new_amount == original_currency_amount:
24+
continue
25+
diff = new_amount - original_currency_amount
26+
currency_diff = new_amount / rate - original_amount
27+
tax_group.update(
28+
{
29+
"tax_amount": new_amount / rate,
30+
"tax_amount_currency": new_amount,
31+
}
32+
)
33+
res["tax_amount"] += diff
34+
res["total_amount"] += diff
35+
res["tax_amount_currency"] += currency_diff
36+
res["total_amount_currency"] += currency_diff
37+
38+
return res

account_invoice_tax/wizards/account_invoice_tax.py

Lines changed: 26 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2-
from odoo import Command, _, api, fields, models
2+
from odoo import Command, api, fields, models
33
from odoo.exceptions import UserError
44

55

@@ -34,24 +34,14 @@ def default_get(self, fields):
3434

3535
def action_update_tax(self):
3636
move = self.move_id
37-
fixed_taxes_bu = {
38-
line.tax_line_id: {
39-
"amount_currency": line.amount_currency,
40-
"debit": line.debit,
41-
"credit": line.credit,
42-
}
43-
for line in self.move_id.line_ids.filtered(
44-
lambda x: x.tax_repartition_line_id.tax_id.amount_type == "fixed"
45-
)
46-
}
4737

4838
active_tax = self.tax_line_ids.mapped("tax_id")
4939
origin_tax = self.move_id.line_ids.filtered(lambda x: x.tax_line_id).mapped("tax_repartition_line_id.tax_id")
5040
to_remove_tax = origin_tax - active_tax
5141
to_add_tax = active_tax - origin_tax
5242
container = {"records": move, "self": move}
5343

54-
# change tax list
44+
# --- 1. Update tax list on invoice lines ---
5545
with move.with_context(check_move_validity=False)._check_balanced(container):
5646
with move._sync_dynamic_lines(container):
5747
if to_remove_tax:
@@ -63,37 +53,33 @@ def action_update_tax(self):
6353
{"tax_ids": [Command.link(tax_id.id) for tax_id in to_add_tax]}
6454
)
6555

66-
# set amount in the new created tax line. En este momento si queda balanceado y se ajusta la linea AP/AR
67-
container = {"records": move}
56+
# --- 2. Persist overrides in the JSON field so they survive recomputations ---
57+
self._save_overrides()
6858

69-
if move.move_type == "in_invoice":
70-
sign = 1
71-
else: # For refund
72-
sign = -1
59+
# --- 3. Apply overrides to the current tax lines ---
60+
container = {"records": move}
7361
with move._check_balanced(container):
7462
with move._sync_dynamic_lines(container):
75-
# restauramos todos los valores de impuestos fixed que se habrian recomputado
76-
# restaured = []
77-
for tax_line in move.line_ids.filtered(
78-
lambda x: x.tax_repartition_line_id.tax_id in fixed_taxes_bu
79-
and x.tax_repartition_line_id.tax_id.amount_type == "fixed"
80-
):
81-
tax_line.write(fixed_taxes_bu.get(tax_line.tax_line_id))
82-
for tax_line_id in self.tax_line_ids:
83-
# seteamos valor al impuesto segun lo que puso en el wizard
84-
line_with_tax = move.line_ids.filtered(lambda x: x.tax_line_id == tax_line_id.tax_id)
85-
line_with_tax.write({"amount_currency": tax_line_id.amount * sign})
86-
87-
def add_tax_and_new(self):
88-
self.add_tax()
89-
return {
90-
"type": "ir.actions.act_window",
91-
"name": _("Edit tax lines"),
92-
"res_model": self._name,
93-
"target": "new",
94-
"view_mode": "form",
95-
"context": self._context,
96-
}
63+
move._apply_tax_overrides()
64+
65+
def _save_overrides(self):
66+
"""Write wizard line amounts into ``tax_override_data`` on the move.
67+
68+
Only fixed-amount taxes are persisted as overrides. Percentage-based
69+
taxes are always recomputed automatically, so any stale entry for them
70+
is removed.
71+
"""
72+
new_overrides = {}
73+
move = self.move_id
74+
for wizard_line in self.tax_line_ids.filtered(lambda l: l.tax_id.amount_type == "fixed"):
75+
new_overrides[str(wizard_line.tax_id.id)] = {
76+
"amount": wizard_line.amount,
77+
"rate": self.move_id.invoice_currency_rate or 1.0,
78+
}
79+
80+
# Previous overrides are fully replaced – entries not present in
81+
# new_overrides (removed taxes or percentage-based taxes) are dropped.
82+
move.tax_override_data = new_overrides or False
9783

9884
@api.constrains("tax_line_ids")
9985
@api.onchange("tax_line_ids")

0 commit comments

Comments
 (0)