From e2340bd5bfe6adc6fef28c8fd29706ba3a76a1d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=CC=81=20Arturo=20Garci=CC=81a?= Date: Fri, 14 Aug 2026 20:54:23 -0400 Subject: [PATCH] Migrate account invoice DO to Tryton 8.0 --- .gitea/workflows/publish.yml | 21 +- CHANGELOG | 11 + COPYRIGHT | 2 +- README.rst | 23 +- __init__.py | 4 - address.py | 11 - cron.py | 91 -------- doc/design.rst | 11 + doc/index.rst | 11 +- doc/usage.rst | 8 + invoice.py | 316 ++++++++++++++-------------- invoice.xml | 1 - locale/es.po | 68 +++++- locale/es_419.po | 58 ++--- pyproject.toml | 9 +- tests/test_invoice_helpers.py | 144 ++++++++----- tests/test_module.py | 115 ++-------- tryton.cfg | 3 - view/invoice_form.xml | 22 +- view/invoice_line_tree_sequence.xml | 5 - 20 files changed, 420 insertions(+), 514 deletions(-) create mode 100644 CHANGELOG delete mode 100755 address.py delete mode 100755 cron.py diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index 72ab4f5..82e4467 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -3,16 +3,24 @@ name: Publish Package on: push: branches: - - "**" + - "8.0" workflow_dispatch: +permissions: + contents: read + jobs: publish: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: https://gitea.com/actions/checkout@v4 + with: + token: ${{ secrets.GITEA_TOKEN }} + github-server-url: "http://unpaismejor-gitea-app:3000" + ref: "8.0" + fetch-depth: 0 - name: Detect package branch run: | @@ -21,7 +29,9 @@ jobs: import re branch = ( - os.environ.get('GITHUB_REF_NAME') + os.environ.get('GITEA_REF_NAME') + or os.environ.get('GITEA_HEAD_REF') + or os.environ.get('GITHUB_REF_NAME') or os.environ.get('GITHUB_HEAD_REF') or '') publish = bool(re.fullmatch(r'\d+\.\d+', branch)) @@ -75,7 +85,7 @@ jobs: OWNER = 'tryton-do' PACKAGE_TYPE = 'pypi' - API_ROOT = 'https://gitea.joseagrc.com/api/v1' + API_ROOT = 'https://code.unpaismejor.org.do/api/v1' def normalize(name): return re.sub(r'[-_.]+', '-', name).lower() @@ -85,7 +95,6 @@ jobs: cfg_match = re.search(r'(?m)^version=(.+)$', cfg_text) if not cfg_match: raise SystemExit('tryton.cfg has no version entry') - series = os.environ['PACKAGE_SERIES'] major, minor = map(int, series.split('.')) @@ -188,5 +197,5 @@ jobs: TWINE_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} run: | python -m twine upload \ - --repository-url https://gitea.joseagrc.com/api/packages/tryton-do/pypi \ + --repository-url https://code.unpaismejor.org.do/api/packages/tryton-do/pypi \ dist/* diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..70d6270 --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,11 @@ +Version 8.0.0 - 2026-08-14 +-------------------------- + +* Align invoice, tax and payment helpers with Tryton 8.0 core APIs. +* Derive informational Dominican tax amounts from the core tax engine. +* Preserve signed paid amounts for invoices and credit notes. +* Compute the final due date from all payment-term maturities. +* Remove unsafe global address defaults and filesystem-writing cron jobs. +* Preserve core and third-party invoice ordering criteria. +* Declare Fundación Un País Mejor as maintainer and package publisher. +* Add Gitea Actions publishing for the 8.0 series. diff --git a/COPYRIGHT b/COPYRIGHT index 08b49e8..052711f 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -1,4 +1,4 @@ -Copyright (C) 2022 Solutema +Copyright (C) 2022 Fundación Un País Mejor This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by diff --git a/README.rst b/README.rst index 18766e3..e418161 100644 --- a/README.rst +++ b/README.rst @@ -6,13 +6,24 @@ Dominican Republic invoice helpers for Tryton. This module extends ``account_invoice`` with operational fields commonly used in Dominican Republic installations: -* salesman on invoices; * due date, elapsed days, overdue days, and paid amount helpers; -* supplier reimbursement party; * informational invoice line tax amounts by Dominican tax kind; -* optional cron methods to process posted customer and supplier invoices. -The tax amount columns are display helpers. They are computed from the line -amount and the selected taxes to make invoice entry easier to review; accounting -tax posting remains handled by Tryton's core tax engine. +The tax amount columns are display helpers. They use the amounts computed by +Tryton's core tax engine and classify them by the stable Dominican tax groups. +Accounting tax posting remains handled exclusively by the core engine. +Installing +********** + +Install the package and activate the module in the database:: + + pip install unpaismejor_account_invoice_do + trytond-admin -d -u account_invoice_do + +Publishing +********** + +The Gitea Actions workflow builds and publishes the package from the ``8.0`` +series branch. Configure ``REGISTRY_USER`` and ``REGISTRY_PASSWORD`` as +repository secrets. diff --git a/__init__.py b/__init__.py index 30622ea..8067eb1 100644 --- a/__init__.py +++ b/__init__.py @@ -1,9 +1,5 @@ -from . import address -from . import cron from . import invoice __all__ = [ - 'address', - 'cron', 'invoice', ] diff --git a/address.py b/address.py deleted file mode 100755 index e08e297..0000000 --- a/address.py +++ /dev/null @@ -1,11 +0,0 @@ -# This file is part of Tryton. The COPYRIGHT file at the top level of -# this repository contains the full copyright notices and license terms. -from trytond.pool import PoolMeta - - -class Address(metaclass=PoolMeta): - __name__ = 'party.address' - - @staticmethod - def default_invoice(): - return True \ No newline at end of file diff --git a/cron.py b/cron.py deleted file mode 100755 index 0fffa2d..0000000 --- a/cron.py +++ /dev/null @@ -1,91 +0,0 @@ -# This file is part of Tryton. The COPYRIGHT file at the top level of -# this repository contains the full copyright notices and license terms. -import logging -import os - -from trytond.pool import PoolMeta - -logger = logging.getLogger(__name__) - - -def _build_error_message(inv, error): - return ( - f"ID: {inv.id}" - f" | Numero: {getattr(inv, 'number', 'N/D')}" - f" | NCF: {getattr(inv, 'ncf_number', 'N/D')}" - f" | Fecha: {getattr(inv, 'invoice_date', 'N/D')}" - f" | Descripcion: {getattr(inv, 'description', 'N/D')}" - f" | Error: {error}" - ) - - -def _write_error_log(path, header, error_lines): - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, 'a', encoding='utf-8') as f: - f.write(f"===== {header} =====\n") - for line in error_lines: - f.write(line + '\n') - - -class Invoice(metaclass=PoolMeta): - __name__ = 'account.invoice' - - @classmethod - def _cron_process_invoices(cls, invoice_type, error_log_filename): - invoices = cls.search([ - ('type', '=', invoice_type), - ('state', '=', 'posted'), - ]) - logger.info( - "cron_process_posted_%s_invoices: found %d invoices", - 'customer' if invoice_type == 'out' else 'supplier', - len(invoices), - ) - - errors = [] - total = len(invoices) - for idx, inv in enumerate(invoices, start=1): - try: - logger.info( - "Processing invoice %d/%d id=%s number=%s", - idx, total, inv.id, getattr(inv, 'number', 'N/D'), - ) - cls.process([inv]) - except Exception as e: - msg = _build_error_message(inv, e) - logger.error(msg, exc_info=True) - errors.append(msg) - - if errors: - log_dir = os.path.expanduser('~/legna/tryton') - _write_error_log( - os.path.join(log_dir, error_log_filename), - f"ERRORES CRON {'CUSTOMER' if invoice_type == 'out' else 'SUPPLIER'} INVOICES", - errors, - ) - logger.warning( - "cron finished with %d error(s)", len(errors)) - else: - logger.info("cron finished successfully") - - @classmethod - def cron_process_posted_customer_invoices(cls): - cls._cron_process_invoices('out', 'invoice_out_cron_errors.log') - - @classmethod - def cron_process_posted_supplier_invoices(cls): - cls._cron_process_invoices('in', 'invoice_in_cron_errors.log') - - -class Cron(metaclass=PoolMeta): - __name__ = 'ir.cron' - - @classmethod - def __setup__(cls): - super().__setup__() - cls.method.selection.extend([ - ('account.invoice|cron_process_posted_customer_invoices', - "Process Posted Customer Invoices"), - ('account.invoice|cron_process_posted_supplier_invoices', - "Process Posted Supplier Invoices"), - ]) diff --git a/doc/design.rst b/doc/design.rst index 108d62d..d380b47 100644 --- a/doc/design.rst +++ b/doc/design.rst @@ -1,3 +1,14 @@ ****** Design ****** + +The module does not reimplement tax formulas. Invoice line helper amounts are +obtained from ``account.invoice.line._get_taxes`` and classified with the tax +group codes supplied by ``account_do``. This preserves Tryton's handling of +fixed, included, compound, dated and rounded taxes. +All informational tax fields and the line total share one multi-field getter, +so the core tax result is classified only once per invoice line read. + +The displayed due date is the last maturity date. For posted invoices it is +read from the payable move lines. For draft invoices it is computed by the +complete payment term using the invoice amount and currency. diff --git a/doc/index.rst b/doc/index.rst index 5643513..c0c9c98 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -6,13 +6,14 @@ Extensiones dominicanas para facturación. Cobertura funcional ------------------- -* Vendedor en factura. -* Fecha de vencimiento calculada desde término de pago. +* Fecha final de vencimiento calculada desde el término de pago completo. * Días transcurridos, días vencidos e importe pagado. -* Totales por tipo de impuesto en líneas de factura. -* Procesamiento cron de facturas publicadas con bitácora de errores. +* Totales informativos por grupo fiscal dominicano en líneas de factura. +* Total de línea basado en los importes producidos por el motor fiscal de + Tryton. Pruebas ------- -Las pruebas cubren vencimiento, importe pagado, cálculo por tipo de impuesto, subtotal y formato de errores del cron. +Las pruebas cubren vencimientos múltiples, notas de crédito, clasificación +fiscal, subtotales y contabilización real de ITBIS en ventas y compras. diff --git a/doc/usage.rst b/doc/usage.rst index 257f9d7..217a1bf 100644 --- a/doc/usage.rst +++ b/doc/usage.rst @@ -1,3 +1,11 @@ ***** Usage ***** + +Select taxes provided by ``account_do`` on an invoice line. The informational +columns show ITBIS, ITBIS withholding, ISC, CDT, ISR withholding and legal tip +amounts. These values are read-only and do not alter accounting entries. + +The invoice payment page displays the final due date, elapsed days, overdue +days and the signed amount paid. Credit notes therefore retain their negative +sign. diff --git a/invoice.py b/invoice.py index c67300e..c5dcb80 100755 --- a/invoice.py +++ b/invoice.py @@ -1,31 +1,22 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. -from dateutil.relativedelta import relativedelta from decimal import Decimal -from datetime import date -from trytond.model import fields -from trytond.pool import PoolMeta -from trytond.modules.product.product import TYPES -from trytond.pyson import Eval -from trytond.modules.currency.fields import Monetary -_ZERO = Decimal('0.00') +from trytond.model import fields +from trytond.modules.currency.fields import Monetary +from trytond.modules.product.product import TYPES +from trytond.pool import Pool, PoolMeta +from trytond.pyson import Eval +from trytond.transaction import Transaction + +_ZERO = Decimal(0) class Invoice(metaclass=PoolMeta): __name__ = 'account.invoice' - employee = fields.Many2One('company.employee', 'Salesman', - domain=[ - ('company', '=', Eval('company', -1)), - ], - states={ - 'readonly': Eval('state') != 'draft', - }, - depends=['state', 'company']) - due_date = fields.Function(fields.Date('Due Date'), - 'get_remainder_time') + 'get_due_date') amount_paid = fields.Function(Monetary( "Amount Paid", currency='currency', digits='currency'), @@ -36,17 +27,6 @@ class Invoice(metaclass=PoolMeta): days_overdue = fields.Function(fields.Integer('Days Overdue'), 'get_days_overdue') - party_reimbursement = fields.Many2One( - 'party.party', 'Party Reimbursement', - states={ - 'invisible': Eval('type') != 'in', - 'readonly': Eval('state') != 'draft', - }, - context={ - 'company': Eval('company', -1), - }, - depends=['company']) - @classmethod def search_rec_name(cls, name, clause): domain = super().search_rec_name(name, clause) @@ -55,31 +35,49 @@ class Invoice(metaclass=PoolMeta): ] def get_days_elapsed(self, name=None): - if self.state == 'paid' or not self.due_date or not self.invoice_date: - return None - days = (date.today() - self.invoice_date).days - return days if days > 0 else None + return self._positive_age(self.invoice_date) def get_days_overdue(self, name=None): - if self.state == 'paid' or not self.due_date or not self.invoice_date: + return self._positive_age(self.due_date) + + def _positive_age(self, reference_date): + if self.state == 'paid' or not reference_date: return None - days = (date.today() - self.due_date).days + days = (self._today() - reference_date).days return days if days > 0 else None - def get_remainder_time(self, name): - term = self.payment_term - if term and term.lines: - for line in term.lines: - if (not line.relativedeltas - or not self.invoice_date): - break - delta = line.relativedeltas[0] - return self.invoice_date + relativedelta( - months=delta.months, - weeks=delta.weeks, - days=delta.days) + def _today(self): + Date = Pool().get('ir.date') + company = self.company.id if self.company else None + with Transaction().set_context(company=company): + return Date.today() + + def _compute_due_date(self): + maturity_date = max(( + line.maturity_date for line in (self.lines_to_pay or []) + if line.maturity_date), default=None) + if maturity_date: + return maturity_date + + payment_date = self.payment_term_date or self.invoice_date + if (self.payment_term and payment_date and self.currency + and self.total_amount is not None): + terms = self.payment_term.compute( + self.total_amount, self.currency, payment_date) + if terms: + return max(date for date, _ in terms) return None + def get_due_date(self, name=None): + return self._compute_due_date() + + @fields.depends( + 'payment_term', 'payment_term_date', 'invoice_date', 'currency', + 'total_amount', 'lines_to_pay', + methods=['_compute_due_date']) + def on_change_with_due_date(self, name=None): + return self._compute_due_date() + @classmethod def get_amount_paid(cls, invoices, name): res = {i.id: _ZERO for i in invoices} @@ -88,7 +86,7 @@ class Invoice(metaclass=PoolMeta): continue total = invoice.total_amount or _ZERO to_pay = invoice.amount_to_pay or _ZERO - paid = abs(total - to_pay) + paid = total - to_pay if invoice.currency: paid = invoice.currency.round(paid) res[invoice.id] = paid @@ -97,10 +95,14 @@ class Invoice(metaclass=PoolMeta): @classmethod def __setup__(cls): super().__setup__() + # Keep the ordering criteria installed by account_invoice and other + # modules. Replacing the whole list would silently discard extension + # hooks such as the core invoice-number ordering. cls._order = [ ('invoice_date', 'DESC'), - ('id', 'DESC'), - ] + *(order for order in cls._order + if order[0] != 'invoice_date'), + ] _LINE_DEPENDS = [ @@ -116,6 +118,28 @@ _LINE_TOTAL_STATES = { 'invisible': ~Eval('type').in_(['line', 'subtotal']), } +_TAX_FIELD_KINDS = { + 'itbis_invoiced_line': 'itbis', + 'itbis_withholding_invoiced_line': 'itbis_withholding', + 'isc_invoiced_line': 'isc', + 'cdt_invoiced_line': 'cdt', + 'isr_withholding_invoiced_line': 'isr_withholding', + 'tip_invoiced_line': 'tip', +} + +_TAX_GROUP_KINDS = { + 'ISC': 'isc', + 'CDT': 'cdt', + 'ISR': 'isr_withholding', + 'ISREXT': 'isr_withholding', +} + + +def _amount_field(label, states=_LINE_STATES): + return fields.Function(Monetary( + label, currency='currency', digits='currency', states=states), + 'get_amounts') + class InvoiceLine(metaclass=PoolMeta): 'Invoice Line' @@ -126,47 +150,13 @@ class InvoiceLine(metaclass=PoolMeta): 'on_change_with_product_type' ) - itbis_invoiced_line = fields.Function(Monetary( - "ITBIS Invoiced", currency='currency', digits='currency', - states=_LINE_STATES), - 'get_itbis_invoiced_line' - ) - - itbis_withholding_invoiced_line = fields.Function(Monetary( - "ITBIS Withholding", currency='currency', digits='currency', - states=_LINE_STATES), - 'get_itbis_withholding_invoiced_line' - ) - - isc_invoiced_line = fields.Function(Monetary( - "ISC Invoiced", currency='currency', digits='currency', - states=_LINE_STATES), - 'get_isc_invoiced_line' - ) - - cdt_invoiced_line = fields.Function(Monetary( - "CDT Invoiced", currency='currency', digits='currency', - states=_LINE_STATES), - 'get_cdt_invoiced_line' - ) - - isr_withholding_invoiced_line = fields.Function(Monetary( - "ISR Withholding", currency='currency', digits='currency', - states=_LINE_STATES), - 'get_isr_withholding_invoiced_line' - ) - - tip_invoiced_line = fields.Function(Monetary( - "Tip Invoiced", currency='currency', digits='currency', - states=_LINE_STATES), - 'get_tip_invoiced_line' - ) - - line_total = fields.Function(Monetary( - "Total", currency='currency', digits='currency', - states=_LINE_TOTAL_STATES), - 'get_line_total' - ) + itbis_invoiced_line = _amount_field("ITBIS Invoiced") + itbis_withholding_invoiced_line = _amount_field("ITBIS Withholding") + isc_invoiced_line = _amount_field("ISC Invoiced") + cdt_invoiced_line = _amount_field("CDT Invoiced") + isr_withholding_invoiced_line = _amount_field("ISR Withholding") + tip_invoiced_line = _amount_field("Tip Invoiced") + line_total = _amount_field("Total", _LINE_TOTAL_STATES) @fields.depends('type', 'product') def on_change_with_product_type(self, name=None): @@ -177,7 +167,7 @@ class InvoiceLine(metaclass=PoolMeta): @classmethod def _get_line_currency(cls, line): invoice = getattr(line, 'invoice', None) - if invoice and hasattr(invoice, 'currency') and invoice.currency: + if invoice and getattr(invoice, 'currency', None): return invoice.currency return getattr(line, 'currency', None) @@ -191,105 +181,109 @@ class InvoiceLine(metaclass=PoolMeta): @classmethod def _get_base_line_amount(cls, line): if line.type != 'line': - return Decimal('0.0') - amount = None - if hasattr(line, 'on_change_with_amount'): - amount = line.on_change_with_amount() + return _ZERO + compute = getattr(line, 'on_change_with_amount', None) + amount = compute() if compute else None if amount is None: amount = getattr(line, 'amount', None) - return amount or Decimal('0.0') + return amount or _ZERO @classmethod - def _compute_tax_kind_amount(cls, line, tax_kind): - if line.type != 'line': - return Decimal('0.0') - taxes = getattr(line, 'taxes', None) - if not taxes: - return Decimal('0.0') - base = cls._get_base_line_amount(line) - total = Decimal('0.0') - for tax in taxes: - if getattr(tax, 'tax_kind', None) == tax_kind: - rate = getattr(tax, 'rate', None) or Decimal('0.0') - total += base * Decimal(str(rate)) - return cls._round_amount(line, abs(total)) + def _is_withholding_tax(cls, tax): + if not tax: + return False + if getattr(tax, 'type', None) == 'percentage': + return (tax.rate or _ZERO) < 0 + if getattr(tax, 'type', None) == 'fixed': + return (tax.amount or _ZERO) < 0 + return False @classmethod - def _compute_line_total_amount(cls, line): + def _tax_kind(cls, tax_line): + tax = getattr(tax_line, 'tax', None) + if not tax: + return None + group_code = getattr(getattr(tax, 'group', None), 'code', None) + if group_code == 'ITBIS': + return ('itbis_withholding' + if cls._is_withholding_tax(tax) else 'itbis') + if group_code in _TAX_GROUP_KINDS: + return _TAX_GROUP_KINDS[group_code] + if group_code == 'OTROS' and getattr(tax, 'template', None): + name = (tax.template.name or '').casefold() + if 'propina' in name or 'tip' in name: + return 'tip' + + @classmethod + def _compute_amounts(cls, line): + amounts = dict.fromkeys(_TAX_FIELD_KINDS.values(), _ZERO) if line.type == 'line': base = cls._get_base_line_amount(line) - tax_total = Decimal('0.0') - for tax in (getattr(line, 'taxes', None) or []): - rate = getattr(tax, 'rate', None) or Decimal('0.0') - tax_total += base * Decimal(str(rate)) - return cls._round_amount(line, base + tax_total) - if line.type == 'subtotal': - subtotal = Decimal('0.0') + tax_total = _ZERO + if getattr(line, 'taxes', None): + for tax_line in line._get_taxes().values(): + amount = tax_line.amount or _ZERO + tax_total += amount + kind = cls._tax_kind(tax_line) + if kind: + amounts[kind] += amount + line_total = base + tax_total + elif line.type == 'subtotal': + subtotal = _ZERO invoice = getattr(line, 'invoice', None) - if not invoice or not hasattr(invoice, 'lines'): - return subtotal - for line2 in invoice.lines: - if line2.type == 'line': - subtotal += cls._compute_line_total_amount(line2) - elif line2.type == 'subtotal': - if line == line2: + for invoice_line in getattr(invoice, 'lines', []): + if invoice_line.type == 'line': + subtotal += cls._compute_amounts(invoice_line)['line_total'] + elif invoice_line.type == 'subtotal': + if line == invoice_line: break - subtotal = Decimal('0.0') - return cls._round_amount(line, subtotal) - return Decimal('0.0') + subtotal = _ZERO + line_total = subtotal + else: + line_total = _ZERO + amounts = { + kind: cls._round_amount(line, abs(amount)) + for kind, amount in amounts.items()} + amounts['line_total'] = cls._round_amount(line, line_total) + return amounts @classmethod - def get_itbis_invoiced_line(cls, lines, name): - return {l.id: cls._compute_tax_kind_amount(l, 'itbis') for l in lines} - - @classmethod - def get_itbis_withholding_invoiced_line(cls, lines, name): - return {l.id: cls._compute_tax_kind_amount(l, 'itbis_withholding') for l in lines} - - @classmethod - def get_isc_invoiced_line(cls, lines, name): - return {l.id: cls._compute_tax_kind_amount(l, 'isc') for l in lines} - - @classmethod - def get_cdt_invoiced_line(cls, lines, name): - return {l.id: cls._compute_tax_kind_amount(l, 'cdt') for l in lines} - - @classmethod - def get_isr_withholding_invoiced_line(cls, lines, name): - return {l.id: cls._compute_tax_kind_amount(l, 'isr_withholding') for l in lines} - - @classmethod - def get_tip_invoiced_line(cls, lines, name): - return {l.id: cls._compute_tax_kind_amount(l, 'tip') for l in lines} - - @classmethod - def get_line_total(cls, lines, name): - return {l.id: cls._compute_line_total_amount(l) for l in lines} + def get_amounts(cls, lines, names): + single = isinstance(names, str) + if single: + names = [names] + result = {name: {} for name in names} + for line in lines: + amounts = cls._compute_amounts(line) + for name in names: + result[name][line.id] = amounts.get( + _TAX_FIELD_KINDS.get(name, name), _ZERO) + return result[names[0]] if single else result @fields.depends(*_LINE_DEPENDS, methods=['on_change_with_amount']) def on_change_with_itbis_invoiced_line(self, name=None): - return InvoiceLine._compute_tax_kind_amount(self, 'itbis') + return self._compute_amounts(self)['itbis'] @fields.depends(*_LINE_DEPENDS, methods=['on_change_with_amount']) def on_change_with_itbis_withholding_invoiced_line(self, name=None): - return InvoiceLine._compute_tax_kind_amount(self, 'itbis_withholding') + return self._compute_amounts(self)['itbis_withholding'] @fields.depends(*_LINE_DEPENDS, methods=['on_change_with_amount']) def on_change_with_isc_invoiced_line(self, name=None): - return InvoiceLine._compute_tax_kind_amount(self, 'isc') + return self._compute_amounts(self)['isc'] @fields.depends(*_LINE_DEPENDS, methods=['on_change_with_amount']) def on_change_with_cdt_invoiced_line(self, name=None): - return InvoiceLine._compute_tax_kind_amount(self, 'cdt') + return self._compute_amounts(self)['cdt'] @fields.depends(*_LINE_DEPENDS, methods=['on_change_with_amount']) def on_change_with_isr_withholding_invoiced_line(self, name=None): - return InvoiceLine._compute_tax_kind_amount(self, 'isr_withholding') + return self._compute_amounts(self)['isr_withholding'] @fields.depends(*_LINE_DEPENDS, methods=['on_change_with_amount']) def on_change_with_tip_invoiced_line(self, name=None): - return InvoiceLine._compute_tax_kind_amount(self, 'tip') + return self._compute_amounts(self)['tip'] @fields.depends(*_LINE_DEPENDS, methods=['on_change_with_amount']) def on_change_with_line_total(self, name=None): - return InvoiceLine._compute_line_total_amount(self) + return self._compute_amounts(self)['line_total'] diff --git a/invoice.xml b/invoice.xml index 99acfc6..1040e95 100755 --- a/invoice.xml +++ b/invoice.xml @@ -3,7 +3,6 @@ this repository contains the full copyright notices and license terms. --> - account.invoice diff --git a/locale/es.po b/locale/es.po index 543d674..5550f44 100644 --- a/locale/es.po +++ b/locale/es.po @@ -10,6 +10,10 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" +msgctxt "field:account.invoice,amount_paid:" +msgid "Amount Paid" +msgstr "Monto pagado" + msgctxt "field:account.invoice,days_elapsed:" msgid "Days Elapsed" msgstr "Días transcurridos" @@ -22,18 +26,38 @@ msgctxt "field:account.invoice,due_date:" msgid "Due Date" msgstr "Fecha de vencimiento" -msgctxt "field:account.invoice,employee:" -msgid "Salesman" -msgstr "Vendedor" - -msgctxt "field:account.invoice,party_reimbursement:" -msgid "Party Reimbursement" -msgstr "Tercero para reembolso" - msgctxt "field:account.invoice.line,product_type:" msgid "Product Type" msgstr "Tipo de producto" +msgctxt "field:account.invoice.line,cdt_invoiced_line:" +msgid "CDT Invoiced" +msgstr "CDT facturado" + +msgctxt "field:account.invoice.line,isc_invoiced_line:" +msgid "ISC Invoiced" +msgstr "ISC facturado" + +msgctxt "field:account.invoice.line,isr_withholding_invoiced_line:" +msgid "ISR Withholding" +msgstr "ISR retenido" + +msgctxt "field:account.invoice.line,itbis_invoiced_line:" +msgid "ITBIS Invoiced" +msgstr "ITBIS facturado" + +msgctxt "field:account.invoice.line,itbis_withholding_invoiced_line:" +msgid "ITBIS Withholding" +msgstr "ITBIS retenido" + +msgctxt "field:account.invoice.line,line_total:" +msgid "Total" +msgstr "Total de línea" + +msgctxt "field:account.invoice.line,tip_invoiced_line:" +msgid "Tip Invoiced" +msgstr "Propina legal facturada" + msgctxt "model:account.invoice.line,string:" msgid "Invoice Line" msgstr "Línea de factura" @@ -54,6 +78,22 @@ msgctxt "model:ir.action,name:act_invoice_out_domain_paid" msgid "Paid" msgstr "Pagado" +msgctxt "model:ir.action.act_window.domain,name:act_invoice_in_domain_cancelled" +msgid "Cancelled" +msgstr "Cancelada" + +msgctxt "model:ir.action.act_window.domain,name:act_invoice_in_domain_paid" +msgid "Paid" +msgstr "Pagada" + +msgctxt "model:ir.action.act_window.domain,name:act_invoice_out_domain_cancelled" +msgid "Cancelled" +msgstr "Cancelada" + +msgctxt "model:ir.action.act_window.domain,name:act_invoice_out_domain_paid" +msgid "Paid" +msgstr "Pagada" + msgctxt "model:ir.ui.view,name:invoice_line_view_form" msgid "invoice_line_form" msgstr "invoice_line_form" @@ -65,3 +105,15 @@ msgstr "invoice_line_tree_sequence" msgctxt "model:ir.ui.view,name:invoice_view_form" msgid "invoice_form" msgstr "invoice_form" + +msgctxt "selection:account.invoice.line,product_type:" +msgid "Assets" +msgstr "Activos" + +msgctxt "selection:account.invoice.line,product_type:" +msgid "Goods" +msgstr "Bienes" + +msgctxt "selection:account.invoice.line,product_type:" +msgid "Service" +msgstr "Servicio" diff --git a/locale/es_419.po b/locale/es_419.po index dd9e3e0..9494bf5 100755 --- a/locale/es_419.po +++ b/locale/es_419.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: account_invoice_do 8.0.0\n" "PO-Revision-Date: 2026-07-22 00:00+0000\n" -"Last-Translator: Solutema\n" +"Last-Translator: Fundación Un País Mejor\n" "Language-Team: Spanish (Latin America)\n" "Language: es_419\n" "MIME-Version: 1.0\n" @@ -12,51 +12,43 @@ msgstr "" msgctxt "field:account.invoice,amount_paid:" msgid "Amount Paid" -msgstr "Monto Pagado" +msgstr "Monto pagado" msgctxt "field:account.invoice,days_elapsed:" msgid "Days Elapsed" -msgstr "Días Transcurridos" +msgstr "Días transcurridos" msgctxt "field:account.invoice,days_overdue:" msgid "Days Overdue" -msgstr "Días Vencidos" +msgstr "Días vencidos" msgctxt "field:account.invoice,due_date:" msgid "Due Date" -msgstr "Fecha de Vencimiento" - -msgctxt "field:account.invoice,employee:" -msgid "Salesman" -msgstr "Vendedor" - -msgctxt "field:account.invoice,party_reimbursement:" -msgid "Party Reimbursement" -msgstr "Tercero de Reembolso" +msgstr "Fecha de vencimiento" msgctxt "field:account.invoice.line,cdt_invoiced_line:" msgid "CDT Invoiced" -msgstr "CDT Facturado" +msgstr "CDT facturado" msgctxt "field:account.invoice.line,isc_invoiced_line:" msgid "ISC Invoiced" -msgstr "ISC Facturado" +msgstr "ISC facturado" msgctxt "field:account.invoice.line,isr_withholding_invoiced_line:" msgid "ISR Withholding" -msgstr "ISR Retenido" +msgstr "ISR retenido" msgctxt "field:account.invoice.line,itbis_invoiced_line:" msgid "ITBIS Invoiced" -msgstr "ITBIS Facturado" +msgstr "ITBIS facturado" msgctxt "field:account.invoice.line,itbis_withholding_invoiced_line:" msgid "ITBIS Withholding" -msgstr "ITBIS Retenido" +msgstr "ITBIS retenido" msgctxt "field:account.invoice.line,line_total:" msgid "Total" -msgstr "Total Línea" +msgstr "Total de línea" msgctxt "field:account.invoice.line,product_type:" msgid "Product Type" @@ -64,7 +56,7 @@ msgstr "Tipo de producto" msgctxt "field:account.invoice.line,tip_invoiced_line:" msgid "Tip Invoiced" -msgstr "Propina Legal" +msgstr "Propina legal facturada" msgctxt "model:account.invoice.line,string:" msgid "Invoice Line" @@ -124,28 +116,4 @@ msgstr "Bienes" msgctxt "selection:account.invoice.line,product_type:" msgid "Service" -msgstr "Servicios" - -msgctxt "selection:ir.cron,method:" -msgid "Process Posted Customer Invoices" -msgstr "Procesar Facturas de Clientes Contabilizadas" - -msgctxt "selection:ir.cron,method:" -msgid "Process Posted Supplier Invoices" -msgstr "Procesar Facturas de Proveedores Contabilizadas" - -msgctxt "selection:product.template,type:" -msgid "Assets" -msgstr "Activos" - -msgctxt "selection:product.template,type:" -msgid "Goods" -msgstr "Bienes" - -msgctxt "selection:product.template,type:" -msgid "Service" -msgstr "Servicios" - -msgctxt "view:account.invoice:" -msgid "Accounting" -msgstr "Contabilidad" +msgstr "Servicio" diff --git a/pyproject.toml b/pyproject.toml index 890710e..27f9f9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,11 +3,11 @@ requires = ['hatchling >= 1', 'hatch-tryton'] build-backend = 'hatchling.build' [project] -name = 'trytond_account_invoice_do' +name = 'unpaismejor_account_invoice_do' dynamic = ['version', 'dependencies', 'optional-dependencies', 'authors', 'readme'] requires-python = '>=3.10' maintainers = [ - {name = "Solutema"}, + {name = "Fundación Un País Mejor", email = "jgarcia@unpaismejor.org.do"}, ] description = "Dominican Republic invoices for Tryton" license = 'GPL-3.0-or-later' @@ -26,11 +26,12 @@ classifiers = [ account_invoice_do = 'trytond.modules.account_invoice_do' [project.urls] -homepage = "https://www.tryton.org/" -repository = "https://gitea.joseagrc.com/tryton-do/account_invoice_do" +homepage = "https://www.unpaismejor.org.do/" +repository = "https://code.unpaismejor.org.do/tryton-do/account_invoice_do" [tool.hatch.build] include = [ + 'CHANGELOG', 'COPYRIGHT', 'LICENSE', 'README.rst', diff --git a/tests/test_invoice_helpers.py b/tests/test_invoice_helpers.py index e1a2960..2cfc3e8 100644 --- a/tests/test_invoice_helpers.py +++ b/tests/test_invoice_helpers.py @@ -1,11 +1,8 @@ +import unittest from datetime import date from decimal import Decimal from types import SimpleNamespace -import unittest -from dateutil.relativedelta import relativedelta - -from trytond.modules.account_invoice_do.cron import _build_error_message from trytond.modules.account_invoice_do.invoice import Invoice, InvoiceLine @@ -15,68 +12,113 @@ class AccountInvoiceDoUnitTestCase(unittest.TestCase): def round(self, amount): return amount.quantize(Decimal('0.01')) - def test_due_date_from_payment_term(self): - invoice = SimpleNamespace() - invoice.payment_term = SimpleNamespace(lines=[ - SimpleNamespace(relativedeltas=[ - relativedelta(months=1, days=5), - ]), - ]) - invoice.invoice_date = date(2026, 1, 10) - self.assertEqual( - Invoice.get_remainder_time(invoice, 'due_date'), - date(2026, 2, 15)) + def _tax_line( + self, code, amount, template_name=None, rate='0.10'): + template = (SimpleNamespace(name=template_name) + if template_name else None) + tax = SimpleNamespace( + group=SimpleNamespace(code=code), template=template, + type='percentage', rate=Decimal(rate), amount=None) + return SimpleNamespace(tax=tax, amount=Decimal(amount)) - def test_amount_paid_only_for_posted_or_paid_invoices(self): - posted = SimpleNamespace( - id=1, state='posted', total_amount=Decimal('118.00'), - amount_to_pay=Decimal('18.00'), currency=self.Currency()) - draft = SimpleNamespace( - id=2, state='draft', total_amount=Decimal('118.00'), - amount_to_pay=Decimal('0.00'), currency=self.Currency()) - self.assertEqual(Invoice.get_amount_paid([posted, draft], 'amount_paid'), { - 1: Decimal('100.00'), - 2: Decimal('0.00'), - }) - - def test_tax_kind_amount_and_line_total(self): + def _line(self, *tax_lines, **values): line = SimpleNamespace( id=1, type='line', + currency=self.Currency(), + invoice=None, amount=Decimal('100.00'), - taxes=[ - SimpleNamespace(tax_kind='itbis', rate=Decimal('0.18')), - SimpleNamespace(tax_kind='tip', rate=Decimal('0.10')), - ], - currency=self.Currency()) + taxes=[tax.tax for tax in tax_lines], + ) + line._get_taxes = lambda: { + index: tax for index, tax in enumerate(tax_lines)} + for name, value in values.items(): + setattr(line, name, value) + return line + + def test_due_date_uses_last_payment_term_maturity(self): + term = SimpleNamespace(compute=lambda amount, currency, start: [ + (date(2026, 2, 10), Decimal('40.00')), + (date(2026, 3, 10), Decimal('60.00')), + ]) + invoice = SimpleNamespace( + lines_to_pay=[], payment_term=term, payment_term_date=None, + invoice_date=date(2026, 1, 10), currency=self.Currency(), + total_amount=Decimal('100.00')) self.assertEqual( - InvoiceLine._compute_tax_kind_amount(line, 'itbis'), - Decimal('18.00')) + Invoice._compute_due_date(invoice), date(2026, 3, 10)) + + def test_due_date_prefers_posted_maturity_dates(self): + invoice = SimpleNamespace(lines_to_pay=[ + SimpleNamespace(maturity_date=date(2026, 2, 10)), + SimpleNamespace(maturity_date=date(2026, 4, 10)), + ]) self.assertEqual( - InvoiceLine.on_change_with_itbis_invoiced_line(line), - Decimal('18.00')) + Invoice._compute_due_date(invoice), date(2026, 4, 10)) + + def test_amount_paid_preserves_credit_note_sign(self): + credit = SimpleNamespace( + id=1, state='posted', total_amount=Decimal('-118.00'), + amount_to_pay=Decimal('-18.00'), currency=self.Currency()) self.assertEqual( - InvoiceLine._compute_line_total_amount(line), - Decimal('128.00')) + Invoice.get_amount_paid([credit], 'amount_paid'), + {1: Decimal('-100.00')}) + + def test_days_elapsed_does_not_require_payment_term(self): + invoice = SimpleNamespace( + state='posted', invoice_date=date(2026, 1, 1), due_date=None, + _today=lambda: date(2026, 1, 11)) + invoice._positive_age = lambda reference: Invoice._positive_age( + invoice, reference) + self.assertEqual(Invoice.get_days_elapsed(invoice), 10) + self.assertIsNone(Invoice.get_days_overdue(invoice)) + + def test_tax_amounts_use_tryton_computed_tax_lines(self): + line = self._line( + self._tax_line('ITBIS', '18.00'), + self._tax_line('ITBIS', '-5.40', rate='-0.054'), + self._tax_line('ISC', '10.00'), + self._tax_line('CDT', '2.00'), + self._tax_line('ISR', '-10.00'), + self._tax_line('OTROS', '10.00', 'Legal Tip 10%')) + self.assertEqual(InvoiceLine._compute_amounts(line), { + 'itbis': Decimal('18.00'), + 'itbis_withholding': Decimal('5.40'), + 'isc': Decimal('10.00'), + 'cdt': Decimal('2.00'), + 'isr_withholding': Decimal('10.00'), + 'tip': Decimal('10.00'), + 'line_total': Decimal('124.60'), + }) + + def test_credit_note_itbis_is_not_classified_as_withholding(self): + line = self._line(self._tax_line('ITBIS', '-18.00', rate='0.18')) + self.assertEqual(InvoiceLine._compute_amounts(line), { + 'itbis': Decimal('18.00'), + 'itbis_withholding': Decimal('0.00'), + 'isc': Decimal('0.00'), + 'cdt': Decimal('0.00'), + 'isr_withholding': Decimal('0.00'), + 'tip': Decimal('0.00'), + 'line_total': Decimal('82.00'), + }) + + def test_tax_kind_ignores_incomplete_external_tax_lines(self): + self.assertIsNone(InvoiceLine._tax_kind(SimpleNamespace(tax=None))) + tax = SimpleNamespace( + group=None, template=None, type='percentage', + rate=Decimal('0.18'), amount=None) + self.assertIsNone(InvoiceLine._tax_kind(SimpleNamespace(tax=tax))) def test_subtotal_resets_after_previous_subtotal(self): invoice = SimpleNamespace() - line1 = SimpleNamespace( - type='line', amount=Decimal('100.00'), taxes=[], currency=None) + line1 = self._line() subtotal1 = SimpleNamespace( id=1, type='subtotal', invoice=invoice, currency=None) - line2 = SimpleNamespace( - type='line', amount=Decimal('50.00'), taxes=[], currency=None) + line2 = self._line(amount=Decimal('50.00')) subtotal2 = SimpleNamespace( id=2, type='subtotal', invoice=invoice, currency=None) invoice.lines = [line1, subtotal1, line2, subtotal2] self.assertEqual( - InvoiceLine._compute_line_total_amount(subtotal2), + InvoiceLine._compute_amounts(subtotal2)['line_total'], Decimal('50.00')) - - def test_cron_error_message_uses_fallbacks(self): - inv = SimpleNamespace(id=10) - message = _build_error_message(inv, ValueError('boom')) - self.assertIn('ID: 10', message) - self.assertIn('Numero: N/D', message) - self.assertIn('Error: boom', message) diff --git a/tests/test_module.py b/tests/test_module.py index 03a0c5a..ea4d6e5 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -1,11 +1,8 @@ import datetime from decimal import Decimal -from types import SimpleNamespace -import unittest from trytond.modules.account.tests.test_module import ( create_chart, get_fiscalyear) -from trytond.modules.account_invoice_do.invoice import InvoiceLine from trytond.modules.company.tests import create_company, set_company from trytond.pool import Pool from trytond.tests.test_tryton import ModuleTestCase, with_transaction @@ -15,7 +12,8 @@ class AccountInvoiceDoTestCase(ModuleTestCase): "Test Account Invoice Do module" module = 'account_invoice_do' - def _prepare_invoice_company(self, chart='account_do.do_account_root'): + def _prepare_invoice_company( + self, chart='account_do.do_account_root_en'): pool = Pool() FiscalYear = pool.get('account.fiscalyear') InvoiceSequence = pool.get('account.fiscalyear.invoice_sequence') @@ -83,59 +81,17 @@ class AccountInvoiceDoTestCase(ModuleTestCase): return lines[0] @with_transaction() - def test_customer_invoice_without_product_posts_and_computes_do_fields(self): + def test_customer_invoice_without_product_computes_do_fields(self): 'Post customer invoice and compute Dominican invoice helper fields' pool = Pool() - Account = pool.get('account.account') - FiscalYear = pool.get('account.fiscalyear') - InvoiceSequence = pool.get('account.fiscalyear.invoice_sequence') Invoice = pool.get('account.invoice') Journal = pool.get('account.journal') - Party = pool.get('party.party') - PaymentTerm = pool.get('account.invoice.payment_term') - Sequence = pool.get('ir.sequence.strict') - SequenceType = pool.get('ir.sequence.type') - today = datetime.date.today() - company = create_company() - create_chart(company) - fiscalyear = get_fiscalyear(company, today=today) - sequence_type, = SequenceType.search([ - ('name', '=', 'Invoice'), - ], limit=1) - invoice_sequence, = Sequence.create([{ - 'name': fiscalyear.name, - 'sequence_type': sequence_type.id, - 'company': company.id, - }]) - fiscalyear.invoice_sequences = [InvoiceSequence( - company=company, - in_invoice_sequence=invoice_sequence, - in_credit_note_sequence=invoice_sequence, - out_invoice_sequence=invoice_sequence, - out_credit_note_sequence=invoice_sequence)] - fiscalyear.save() - FiscalYear.create_period([fiscalyear]) - - receivable, = Account.search([ - ('company', '=', company.id), - ('type.receivable', '=', True), - ('party_required', '=', True), - ], limit=1) - revenue, = Account.search([ - ('company', '=', company.id), - ('type.revenue', '=', True), - ('closed', '!=', True), - ], limit=1) + company, term, today = self._prepare_invoice_company() + receivable = self._account(company, '110201') + revenue = self._account(company, '4102') journal, = Journal.search([('type', '=', 'revenue')], limit=1) - party, = Party.create([{ - 'name': 'Cliente local', - 'addresses': [('create', [{}])], - }]) - term, = PaymentTerm.create([{ - 'name': 'Contado', - 'lines': [('create', [{'type': 'remainder'}])], - }]) + party = self._party('Cliente local') with set_company(company): invoice, = Invoice.create([{ @@ -182,8 +138,8 @@ class AccountInvoiceDoTestCase(ModuleTestCase): payable = self._account(company, '210101') revenue = self._account(company, '4102') expense = self._account(company, '6103') - sale_tax = self._tax(company, 'ITBIS 18% Ventas') - purchase_tax = self._tax(company, 'ITBIS 18% Compras') + sale_tax = self._tax(company, 'ITBIS 18% Sales') + purchase_tax = self._tax(company, 'ITBIS 18% Purchases') revenue_journal, = Journal.search([('type', '=', 'revenue')], limit=1) expense_journal, = Journal.search([('type', '=', 'expense')], limit=1) @@ -237,61 +193,20 @@ class AccountInvoiceDoTestCase(ModuleTestCase): in_invoice = Invoice(in_invoice.id) self.assertEqual(out_invoice.tax_amount, Decimal('180.00')) + out_line, = out_invoice.lines + self.assertEqual(out_line.itbis_invoiced_line, Decimal('180.00')) + self.assertEqual(out_line.line_total, Decimal('1180.00')) out_tax_line = self._invoice_tax_line(out_invoice, '21020101') self.assertEqual(out_tax_line.debit, Decimal('0.00')) self.assertEqual(out_tax_line.credit, Decimal('180.00')) self.assertEqual(in_invoice.tax_amount, Decimal('180.00')) + in_line, = in_invoice.lines + self.assertEqual(in_line.itbis_invoiced_line, Decimal('180.00')) + self.assertEqual(in_line.line_total, Decimal('1180.00')) in_tax_line = self._invoice_tax_line(in_invoice, '11040101') self.assertEqual(in_tax_line.debit, Decimal('180.00')) self.assertEqual(in_tax_line.credit, Decimal('0.00')) del ModuleTestCase - - -class AccountInvoiceDoUnitTestCase(unittest.TestCase): - - def _currency(self): - return SimpleNamespace(round=lambda amount: amount.quantize( - Decimal('0.01'))) - - def _tax(self, kind, rate): - return SimpleNamespace(tax_kind=kind, rate=Decimal(rate)) - - def _line(self, **values): - defaults = { - 'id': 1, - 'type': 'line', - 'product': None, - 'taxes': [], - 'currency': self._currency(), - 'amount': Decimal('100.00'), - } - defaults.update(values) - return SimpleNamespace(**defaults) - - def test_product_type_is_empty_without_product(self): - line = self._line() - self.assertEqual(InvoiceLine.on_change_with_product_type(line), '') - - def test_tax_kind_amounts_are_computed_from_line_base(self): - line = self._line(taxes=[ - self._tax('itbis', '0.18'), - self._tax('isc', '0.10'), - ]) - self.assertEqual( - InvoiceLine._compute_tax_kind_amount(line, 'itbis'), - Decimal('18.00')) - self.assertEqual( - InvoiceLine._compute_tax_kind_amount(line, 'isc'), - Decimal('10.00')) - - def test_line_total_includes_all_line_taxes(self): - line = self._line(taxes=[ - self._tax('itbis', '0.18'), - self._tax('cdt', '0.02'), - ]) - self.assertEqual( - InvoiceLine._compute_line_total_amount(line), - Decimal('120.00')) diff --git a/tryton.cfg b/tryton.cfg index bb68050..7b2205f 100644 --- a/tryton.cfg +++ b/tryton.cfg @@ -10,8 +10,5 @@ xml: [register] model: - cron.Cron - cron.Invoice - address.Address invoice.Invoice invoice.InvoiceLine diff --git a/view/invoice_form.xml b/view/invoice_form.xml index 8992313..b2a2bf9 100755 --- a/view/invoice_form.xml +++ b/view/invoice_form.xml @@ -2,23 +2,21 @@ - - - - + - + - - \ No newline at end of file + diff --git a/view/invoice_line_tree_sequence.xml b/view/invoice_line_tree_sequence.xml index d1ef591..59b9058 100644 --- a/view/invoice_line_tree_sequence.xml +++ b/view/invoice_line_tree_sequence.xml @@ -2,10 +2,6 @@ - - @@ -23,5 +19,4 @@ this repository contains the full copyright notices and license terms. --> -