441 lines
16 KiB
Python
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
|