Files
account_credit_note/invoice.py
T

441 lines
16 KiB
Python

# This file is part of account_credit_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 Wizard
logger = logging.getLogger(__name__)
_CREDIT_PARTIAL_MODE = [
('full', 'Total (100%)'),
('percent', 'Percentage'),
('amount', 'Amount'),
]
_CREDIT_AMOUNT_BASIS = [
('untaxed', 'Without Taxes'),
('total', 'With Taxes'),
]
_ZERO = Decimal('0')
_ONE = Decimal('1')
_ONE_HUNDRED = Decimal('100')
def _decimal(value):
"""Return value as Decimal using str() to avoid binary float artifacts."""
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 to resolve the real scale of account.invoice.line.unit_price.
Some Tryton fields expose fixed digits and others expose PYSON-dependent
digits. When the scale can not be resolved safely, use a conservative
default compatible with product price precision.
"""
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 _credit_partial_compute_factor(cls, origin, mode, percent, amount, basis):
if mode == 'full':
return _ONE
if mode == 'percent':
if percent is None:
raise UserError(gettext(
'account_credit_note.msg_missing_credit_percent'))
percent = _decimal(percent)
# Si la vista usa factor="100", Tryton puede guardar 20% como 0.20.
# Si la vista no usa factor, puede guardar 20% como 20.
# Soportamos ambos casos.
if percent <= _ZERO:
raise UserError(gettext(
'account_credit_note.msg_invalid_credit_percent'))
if percent <= _ONE:
return percent
if percent <= _ONE_HUNDRED:
return percent / _ONE_HUNDRED
raise UserError(gettext(
'account_credit_note.msg_invalid_credit_percent'))
if mode == 'amount':
if amount is None:
raise UserError(gettext(
'account_credit_note.msg_missing_credit_amount'))
amount = _decimal(amount)
if amount <= _ZERO:
raise UserError(gettext(
'account_credit_note.msg_invalid_credit_amount'))
if basis == 'untaxed':
base = abs(_decimal(origin.untaxed_amount))
else:
base = abs(_decimal(origin.total_amount))
if base <= _ZERO:
raise UserError(gettext(
'account_credit_note.msg_invalid_credit_base'))
if amount > base:
raise UserError(gettext(
'account_credit_note.msg_credit_amount_gt_base'))
return amount / base
raise UserError(gettext(
'account_credit_note.msg_invalid_credit_mode'))
@classmethod
def _credit_partial_apply_negative_by_unit_price(
cls, origin, credit_note, factor):
"""
Apply a partial credit note by value, not by returned quantity.
Business rule implemented here:
- The credit note line must be negative.
- The absolute quantity from the generated credit note is preserved.
- The unit price is kept positive.
- The unit price is reduced by the partial factor.
Example:
Original invoice: quantity=1, unit_price=23849.00, amount=23849.00
20% credit note: quantity=-1, unit_price=4769.80, amount=-4769.80
This avoids invalid quantities such as -0.002 when the product unit only
allows 2 decimal places.
"""
Line = Pool().get('account.invoice.line')
lines = [line for line in credit_note.lines if line.type == 'line']
if not lines:
return
touched = []
for line in lines:
quantity = _decimal(line.quantity)
unit_price = _decimal(line.unit_price)
scale = _unit_price_scale(line)
# Quantity remains the same magnitude, but the credit-note sign is
# forced to negative. We do not multiply quantity by the factor.
line.quantity = -abs(quantity)
# Unit price is always positive and is the value being reduced.
line.unit_price = _round_decimal(abs(unit_price) * factor, scale)
touched.append(line)
logger.debug(
'Partial credit line adjusted: invoice=%s line=%s '
'factor=%s quantity=%s unit_price=%s',
getattr(credit_note, 'id', None),
getattr(line, 'id', None),
factor,
line.quantity,
line.unit_price)
Line.save(touched)
cls._credit_partial_adjust_last_line(origin, touched, factor)
@classmethod
def _credit_partial_adjust_last_line(cls, origin, lines, factor):
"""
Correct the last line for rounding differences.
The correction is made against the untaxed amount because taxes are
recalculated by Tryton from the adjusted base lines.
"""
Line = Pool().get('account.invoice.line')
if not lines:
return
target_untaxed_abs = abs(_decimal(origin.untaxed_amount)) * factor
current_untaxed_abs = sum(
abs(_decimal(cls._credit_partial_line_amount(line)))
for line in lines)
difference = target_untaxed_abs - current_untaxed_abs
if abs(difference) < Decimal('0.0001'):
return
last = lines[-1]
quantity = _decimal(last.quantity)
if quantity == _ZERO:
return
unit_price_abs = abs(_decimal(last.unit_price))
unit_price_delta_abs = difference / abs(quantity)
new_unit_price_abs = unit_price_abs + unit_price_delta_abs
if new_unit_price_abs < _ZERO:
new_unit_price_abs = _ZERO
scale = _unit_price_scale(last)
last.quantity = -abs(quantity)
last.unit_price = _round_decimal(new_unit_price_abs, scale)
Line.save([last])
logger.debug(
'Partial credit rounding adjustment: line=%s difference=%s '
'quantity=%s unit_price=%s',
getattr(last, 'id', None),
difference,
last.quantity,
last.unit_price)
@classmethod
def _credit_partial_line_amount(cls, line):
return _decimal(line.quantity) * _decimal(line.unit_price)
@classmethod
def _credit_partial_force_negative_sign(cls, credit_note):
"""
Final safety guard: every normal line in the credit note must be negative.
"""
Line = Pool().get('account.invoice.line')
touched = []
for line in credit_note.lines:
if line.type != 'line' or not line.quantity:
continue
quantity = _decimal(line.quantity)
unit_price = _decimal(line.unit_price)
# We want quantity < 0 and unit_price >= 0, therefore amount <= 0.
new_quantity = -abs(quantity)
new_unit_price = abs(unit_price)
if quantity != new_quantity or unit_price != new_unit_price:
line.quantity = new_quantity
line.unit_price = new_unit_price
touched.append(line)
if touched:
Line.save(touched)
@classmethod
def _credit_partial_recompute_taxes(cls, credit_note):
"""
Recompute invoice tax lines after changing credit-note lines.
Tryton's core credit() creates the credit note from the original
invoice. If we later reduce unit_price for a partial credit note, the
copied tax lines may still contain the original bases and amounts.
Calling on_change_lines() forces Tryton to rebuild taxes from the
adjusted invoice lines.
"""
try:
before = [
(
getattr(getattr(tax_line, 'tax', None), 'id', None),
_decimal(getattr(tax_line, 'base', None)),
_decimal(getattr(tax_line, 'amount', None)),
)
for tax_line in (getattr(credit_note, 'taxes', None) or [])
]
if hasattr(credit_note, 'on_change_lines'):
credit_note.on_change_lines()
elif hasattr(credit_note, '_get_taxes'):
InvoiceTax = Pool().get('account.invoice.tax')
credit_note.taxes = [
InvoiceTax(**values)
for values in credit_note._get_taxes().values()
]
else:
logger.warning(
'Could not recompute taxes for credit note %s: no known '
'tax recomputation hook found',
getattr(credit_note, 'id', None))
return
after = [
(
getattr(getattr(tax_line, 'tax', None), 'id', None),
_decimal(getattr(tax_line, 'base', None)),
_decimal(getattr(tax_line, 'amount', None)),
)
for tax_line in (getattr(credit_note, 'taxes', None) or [])
]
logger.debug(
'Partial credit taxes recomputed: invoice=%s before=%s after=%s',
getattr(credit_note, 'id', None), before, after)
except Exception:
logger.exception(
'Error recomputing taxes for partial credit note %s',
getattr(credit_note, 'id', None))
raise
@classmethod
def credit(cls, invoices, refund=False, **values):
"""
Extend account.invoice.credit with partial credit-note support.
Supported modes:
- full: keep the exact core Tryton behavior.
- percent: keep quantity magnitude and reduce unit_price by percentage.
- amount: compute factor from amount/base and reduce unit_price.
The module does not scale quantities because Tryton validates quantity
precision according to the product unit.
Refund behavior:
- For full credit notes, this preserves the core refund behavior.
- For partial credit notes, refund is rejected because Tryton's refund
workflow cancels the original invoice, which is not correct for a
partial credit note.
"""
credit_mode = values.pop('credit_mode', None) or 'full'
credit_percent = values.pop('credit_percent', None)
credit_amount = values.pop('credit_amount', None)
credit_amount_basis = values.pop('credit_amount_basis', None) or 'total'
factors = [
cls._credit_partial_compute_factor(
origin, credit_mode, credit_percent, credit_amount,
credit_amount_basis)
for origin in invoices
]
is_partial = any(factor != _ONE for factor in factors)
if refund and is_partial:
raise UserError(gettext(
'account_credit_note.msg_partial_refund_not_allowed'))
logger.debug(
'Creating credit invoices: mode=%s basis=%s refund=%s invoices=%s',
credit_mode,
credit_amount_basis,
refund,
[getattr(invoice, 'id', None) for invoice in invoices])
# Always create first without refund so the generated credit note can be
# adjusted before posting/cancelling the original invoice.
new_invoices = super().credit(invoices, refund=False, **values)
for origin, credit_note, factor in zip(invoices, new_invoices, factors):
if factor != _ONE:
cls._credit_partial_apply_negative_by_unit_price(
origin, credit_note, factor)
cls._credit_partial_force_negative_sign(credit_note)
cls._credit_partial_recompute_taxes(credit_note)
cls.save(new_invoices)
if refund:
# Same behavior as account_invoice.Invoice.credit(refund=True), but
# executed after possible edits to the generated credit note.
cls.post(new_invoices)
for invoice, new_invoice in zip(invoices, new_invoices):
if invoice.state != 'posted':
raise UserError(gettext(
'account_invoice.msg_invoice_credit_refund_not_posted',
invoice=invoice.rec_name))
invoice.cancel_move = new_invoice.move
cls.save(invoices)
cls.cancel(invoices)
return new_invoices
class CreditInvoiceStart(ModelView, metaclass=PoolMeta):
__name__ = 'account.invoice.credit.start'
credit_mode = fields.Selection(
_CREDIT_PARTIAL_MODE, 'Credit Type', required=True)
credit_percent = fields.Numeric(
'Credit Percentage', digits=(16, 4),
states={
'invisible': Eval('credit_mode') != 'percent',
'required': Eval('credit_mode') == 'percent',
},
depends=['credit_mode'])
credit_amount = fields.Numeric(
'Credit Amount', digits=(16, 2),
states={
'invisible': Eval('credit_mode') != 'amount',
'required': Eval('credit_mode') == 'amount',
},
depends=['credit_mode'])
credit_amount_basis = fields.Selection(
_CREDIT_AMOUNT_BASIS, 'Amount Basis',
states={
'invisible': Eval('credit_mode') != 'amount',
'required': Eval('credit_mode') == 'amount',
},
depends=['credit_mode'])
@staticmethod
def default_credit_mode():
return 'full'
@staticmethod
def default_credit_amount_basis():
return 'total'
@fields.depends('credit_mode', 'with_refund')
def on_change_credit_mode(self):
"""
Full credit note:
- refund checked by default.
Partial credit note:
- refund unchecked because refund cancels the original invoice.
"""
if self.credit_mode == 'full':
self.with_refund = True
elif self.credit_mode in ('percent', 'amount'):
self.with_refund = False
class CreditInvoice(Wizard, metaclass=PoolMeta):
__name__ = 'account.invoice.credit'
def default_start(self, fields_):
default = super().default_start(fields_)
default.setdefault('credit_mode', 'full')
default.setdefault('credit_percent', None)
default.setdefault('credit_amount', None)
default.setdefault('credit_amount_basis', 'total')
return default
@property
def _credit_options(self):
options = super()._credit_options
options.update({
'credit_mode': self.start.credit_mode,
'credit_percent': self.start.credit_percent,
'credit_amount': self.start.credit_amount,
'credit_amount_basis': self.start.credit_amount_basis,
})
return options