Files
account_debit_note/invoice.py
T

329 lines
11 KiB
Python
Raw Normal View History

2026-07-22 20:03:17 -04:00
# This file is part of account_debit_note.
# The COPYRIGHT file at the top level of this repository contains
# the full copyright notices and license terms.
import logging
from decimal import Decimal, ROUND_HALF_UP
from trytond.exceptions import UserError
from trytond.i18n import gettext
from trytond.model import ModelView, fields
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Eval
from trytond.wizard import Button, StateAction, StateView, Wizard
logger = logging.getLogger(__name__)
_DEBIT_MODE = [
('full', 'Total (100%)'),
('percent', 'Percentage'),
('amount', 'Amount'),
]
_DEBIT_AMOUNT_BASIS = [
('untaxed', 'Without Taxes'),
('total', 'With Taxes'),
]
_ZERO = Decimal('0')
_ONE = Decimal('1')
_ONE_HUNDRED = Decimal('100')
def _decimal(value):
2026-08-14 20:07:14 -04:00
"""Return *value* as Decimal without binary floating-point artifacts."""
2026-07-22 20:03:17 -04:00
if value is None:
return _ZERO
return Decimal(str(value))
def _round_decimal(value, scale):
quantum = Decimal('1').scaleb(-scale)
return value.quantize(quantum, rounding=ROUND_HALF_UP)
def _unit_price_scale(line, default=5):
try:
digits = line.__class__._fields['unit_price'].digits
if isinstance(digits, (tuple, list)) and len(digits) == 2:
return int(digits[1])
except Exception:
logger.debug(
'Could not resolve unit_price digits for invoice line %s',
getattr(line, 'id', None),
exc_info=True)
return int(default)
class Invoice(metaclass=PoolMeta):
__name__ = 'account.invoice'
@classmethod
def _debit_compute_factor(cls, origin, mode, percent, amount, basis):
if mode == 'full':
return _ONE
if mode == 'percent':
if percent is None:
raise UserError(gettext(
'account_debit_note.msg_missing_debit_percent'))
percent = _decimal(percent)
if percent <= _ZERO:
raise UserError(gettext(
'account_debit_note.msg_invalid_debit_percent'))
if percent <= _ONE:
return percent
if percent <= _ONE_HUNDRED:
return percent / _ONE_HUNDRED
raise UserError(gettext(
'account_debit_note.msg_invalid_debit_percent'))
if mode == 'amount':
if amount is None:
raise UserError(gettext(
'account_debit_note.msg_missing_debit_amount'))
amount = _decimal(amount)
if amount <= _ZERO:
raise UserError(gettext(
'account_debit_note.msg_invalid_debit_amount'))
if basis == 'untaxed':
base = abs(_decimal(origin.untaxed_amount))
else:
base = abs(_decimal(origin.total_amount))
if base <= _ZERO:
raise UserError(gettext(
'account_debit_note.msg_invalid_debit_base'))
return amount / base
raise UserError(gettext(
'account_debit_note.msg_invalid_debit_mode'))
def _debit(self, **values):
"""
Return a new invoice that increases the amount of this invoice.
Tryton core exposes Invoice._credit(), which copies the invoice and
reverses line signs. Reusing it keeps the same field-copying behavior
2026-08-14 20:07:14 -04:00
as the core credit flow; the debit note then reverses those signs once
more to restore the exact signs of the source invoice.
2026-07-22 20:03:17 -04:00
"""
debit = self._credit(**values)
self.__class__._debit_force_positive_sign(debit)
return debit
@classmethod
def _debit_force_positive_sign(cls, debit_note):
2026-08-14 20:07:14 -04:00
"""Undo the sign reversal performed by Tryton's core ``_credit``.
Do not use ``abs`` here: invoices may legitimately contain negative
adjustment lines or manual taxes. A debit note must preserve those
relative signs rather than turn every component positive.
"""
2026-07-22 20:03:17 -04:00
touched = []
for line in debit_note.lines:
if line.type != 'line':
continue
quantity = _decimal(line.quantity)
2026-08-14 20:07:14 -04:00
new_quantity = -quantity
2026-07-22 20:03:17 -04:00
2026-08-14 20:07:14 -04:00
if quantity != new_quantity:
2026-07-22 20:03:17 -04:00
line.quantity = new_quantity
touched.append(line)
if touched and all(getattr(line, 'id', None) for line in touched):
2026-08-14 20:07:14 -04:00
Line = Pool().get('account.invoice.line')
2026-07-22 20:03:17 -04:00
Line.save(touched)
for tax in getattr(debit_note, 'taxes', None) or []:
2026-08-14 20:07:14 -04:00
tax.base = -_decimal(tax.base)
tax.amount = -_decimal(tax.amount)
2026-07-22 20:03:17 -04:00
@classmethod
def _debit_apply_factor(cls, origin, debit_note, factor):
if factor == _ONE:
return
lines = [line for line in debit_note.lines if line.type == 'line']
touched = []
for line in lines:
unit_price = _decimal(line.unit_price)
scale = _unit_price_scale(line)
2026-08-14 20:07:14 -04:00
line.unit_price = _round_decimal(unit_price * factor, scale)
2026-07-22 20:03:17 -04:00
touched.append(line)
if touched and all(getattr(line, 'id', None) for line in touched):
2026-08-14 20:07:14 -04:00
Line = Pool().get('account.invoice.line')
2026-07-22 20:03:17 -04:00
Line.save(touched)
cls._debit_adjust_last_line(origin, touched, factor)
@classmethod
def _debit_adjust_last_line(cls, origin, lines, factor):
if not lines:
return
2026-08-14 20:07:14 -04:00
target_untaxed = _decimal(origin.untaxed_amount) * factor
current_untaxed = sum(
(_decimal(cls._debit_line_amount(line)) for line in lines),
_ZERO)
difference = target_untaxed - current_untaxed
2026-07-22 20:03:17 -04:00
if abs(difference) < Decimal('0.0001'):
return
last = lines[-1]
quantity = _decimal(last.quantity)
if quantity == _ZERO:
return
2026-08-14 20:07:14 -04:00
new_unit_price = _decimal(last.unit_price) + difference / quantity
last.unit_price = _round_decimal(
new_unit_price, _unit_price_scale(last))
2026-07-22 20:03:17 -04:00
if getattr(last, 'id', None):
2026-08-14 20:07:14 -04:00
Line = Pool().get('account.invoice.line')
2026-07-22 20:03:17 -04:00
Line.save([last])
@classmethod
def _debit_line_amount(cls, line):
2026-08-14 20:07:14 -04:00
on_change_with_amount = getattr(line, 'on_change_with_amount', None)
if on_change_with_amount:
return on_change_with_amount()
2026-07-22 20:03:17 -04:00
return _decimal(line.quantity) * _decimal(line.unit_price)
@classmethod
def _debit_recompute_taxes(cls, debit_note):
if hasattr(debit_note, 'on_change_lines'):
debit_note.on_change_lines()
elif hasattr(debit_note, '_get_taxes'):
InvoiceTax = Pool().get('account.invoice.tax')
debit_note.taxes = [
InvoiceTax(**values)
for values in debit_note._get_taxes().values()
]
@classmethod
def _debit_prepare_note_from_origin(cls, origin, debit_note, values):
"""
Extension hook for localization/fiscal modules.
The base account_debit_note module only creates the accounting debit
note. Fiscal and electronic fields must be provided by their owning
modules.
"""
return None
@classmethod
def debit(cls, invoices, **values):
debit_mode = values.pop('debit_mode', None) or 'full'
debit_percent = values.pop('debit_percent', None)
debit_amount = values.pop('debit_amount', None)
debit_amount_basis = values.pop('debit_amount_basis', None) or 'total'
2026-08-14 20:07:14 -04:00
if debit_mode == 'amount' and len(invoices) != 1:
raise UserError(gettext(
'account_debit_note.msg_amount_multiple_invoices'))
2026-07-22 20:03:17 -04:00
factors = [
cls._debit_compute_factor(
origin, debit_mode, debit_percent, debit_amount,
debit_amount_basis)
for origin in invoices
]
new_invoices = [
origin._debit(invoice_date=values.get('invoice_date'))
for origin in invoices
]
for origin, debit_note, factor in zip(invoices, new_invoices, factors):
cls._debit_apply_factor(origin, debit_note, factor)
cls._debit_recompute_taxes(debit_note)
cls._debit_prepare_note_from_origin(origin, debit_note, values)
cls.save(new_invoices)
return new_invoices
class DebitInvoiceStart(ModelView):
'Debit Invoice'
__name__ = 'account.invoice.debit.start'
invoice_date = fields.Date('Invoice Date')
debit_mode = fields.Selection(
_DEBIT_MODE, 'Debit Type', required=True)
debit_percent = fields.Numeric(
'Debit Percentage', digits=(16, 4),
states={
'invisible': Eval('debit_mode') != 'percent',
'required': Eval('debit_mode') == 'percent',
},
depends=['debit_mode'])
debit_amount = fields.Numeric(
'Debit Amount', digits=(16, 2),
states={
'invisible': Eval('debit_mode') != 'amount',
'required': Eval('debit_mode') == 'amount',
},
depends=['debit_mode'])
debit_amount_basis = fields.Selection(
_DEBIT_AMOUNT_BASIS, 'Amount Basis',
states={
'invisible': Eval('debit_mode') != 'amount',
'required': Eval('debit_mode') == 'amount',
},
depends=['debit_mode'])
@staticmethod
def default_debit_mode():
return 'full'
@staticmethod
def default_debit_amount_basis():
return 'total'
class DebitInvoice(Wizard):
'Debit Invoice'
__name__ = 'account.invoice.debit'
start = StateView('account.invoice.debit.start',
'account_debit_note.debit_start_view_form', [
Button('Cancel', 'end', 'tryton-cancel'),
Button('Debit', 'debit', 'tryton-ok', default=True),
])
debit = StateAction('account_invoice.act_invoice_form')
def default_start(self, fields_):
default = {
'debit_mode': 'full',
'debit_percent': None,
'debit_amount': None,
'debit_amount_basis': 'total',
}
return default
@property
def _debit_options(self):
return {
'invoice_date': self.start.invoice_date,
'debit_mode': self.start.debit_mode,
'debit_percent': self.start.debit_percent,
'debit_amount': self.start.debit_amount,
'debit_amount_basis': self.start.debit_amount_basis,
}
def do_debit(self, action):
debit_invoices = self.model.debit(
self.records, **self._debit_options)
data = {'res_id': [i.id for i in debit_invoices]}
if len(debit_invoices) == 1:
action['views'].reverse()
return action, data