# 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