addons-cm/membership_payment_date/models/account_move.py
GitHub Copilot fbd43bb459
Some checks failed
CI / lint (pull_request) Successful in 33s
CI / test (pull_request) Failing after 9m29s
CI / review (pull_request) Has been skipped
[REF] all: pass pre-commit on the whole repo
Needed for the lint job of the CI to go green. Mostly automatic hook
fixes (black, isort, prettier, pyupgrade, autoflake, whitespace). By hand:

- flake8: two unused imports, an ambiguous variable name `l`.
- pylint-odoo mandatory: manifest keys equal to their default value
  (installable, application, data, external_dependencies).
- end-of-file-fixer skips *.patch too, like trailing-whitespace: the
  upstream patches stay as git format-patch wrote them.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:34:58 +02:00

137 lines
5.2 KiB
Python

# Copyright 2025 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import math
from datetime import timedelta
from odoo import fields
from odoo import models
class AccountMove(models.Model):
_inherit = "account.move"
def _invoice_paid_hook(self):
"""Override: update membership_lines dates upon invoice payment.
RF-01: Use the payment date instead of the invoice date for date_from.
RF-02: Renewals: if the member has an active membership, the new one
starts the day after the current one expires, avoiding overlap.
RF-03: For variable products, recalculate date_to from the new date_from.
"""
res = super()._invoice_paid_hook()
for invoice in self.filtered(lambda m: m.move_type == "out_invoice"):
membership_lines = self.env["membership.membership_line"].search(
[
("account_invoice_line", "in", invoice.invoice_line_ids.ids),
("date_cancel", "=", False),
]
)
if not membership_lines:
continue
payment_date = self._get_membership_payment_date(invoice)
for mline in membership_lines:
partner = mline.partner
product = mline.membership_id
quantity = (
mline.account_invoice_line.quantity
if mline.account_invoice_line
else 1.0
)
date_from, date_to = self._compute_membership_dates(
partner,
product,
payment_date,
quantity,
exclude_line_ids=membership_lines.ids,
)
mline.write(
{
"date_from": date_from,
"date_to": date_to,
}
)
return res
def _get_membership_payment_date(self, invoice):
"""Get the date of the most recent payment reconciled with the invoice.
Uses the ``_get_reconciled_payments()`` helper instead of the
``reconciled_payment_ids`` field: that field was dropped in 18.0 and
only reintroduced on 2025-10-13 (odoo/odoo d8e7798d2c), so it is
missing on older 18.0 builds. ``matched_payment_ids`` is not an option
either: the register wizard links it *after* reconciling, so it is
still empty when ``_invoice_paid_hook`` runs.
If nothing is reconciled (e.g. zero-amount invoice), fallback to today.
"""
payments = invoice._get_reconciled_payments()
if payments:
return max(payments.mapped("date"))
# Counterparts that are not payments: credit notes, manual entries...
reconciled_amls = invoice._get_reconciled_amls()
if reconciled_amls:
return max(reconciled_amls.mapped("date"))
return fields.Date.today()
def _compute_membership_dates(
self,
partner,
product,
payment_date,
quantity=1.0,
exclude_line_ids=None,
):
"""Calculate date_from and date_to for a membership line.
:param partner: res.partner (member)
:param product: product.product (membership product)
:param payment_date: effective payment date
:param quantity: product quantity on the invoice line
:param exclude_line_ids: membership line IDs to exclude from the
active membership search (to avoid matching the lines we are
currently updating)
:return: tuple (date_from, date_to)
"""
domain = [
("partner", "=", partner.id),
("date_cancel", "=", False),
("state", "in", ("paid", "invoiced")),
("date_to", ">=", fields.Date.today()),
]
if exclude_line_ids:
domain.append(("id", "not in", exclude_line_ids))
# RF-02: Look for an active membership (not cancelled, not expired)
active_membership = self.env["membership.membership_line"].search(
domain,
limit=1,
order="date_to desc",
)
if active_membership:
# RF-02: Renewal — start the day after the current one expires
date_from = active_membership.date_to + timedelta(days=1)
else:
# RF-01: New membership — use the payment date
date_from = payment_date
# RF-03: Calculate date_to according to membership type
if product.membership_type == "variable":
qty = int(math.ceil(quantity))
next_date = product._get_next_date(date_from, qty=qty)
date_to = next_date and (next_date - timedelta(days=1)) or False
else:
date_to = product.membership_date_to
# Safety: respect the product's lower bound
if product.membership_date_from and date_from < product.membership_date_from:
date_from = product.membership_date_from
# Safety: ensure date_from does not exceed date_to
if date_to and date_from > date_to:
if active_membership:
date_from = date_to
else:
date_from = min(payment_date, date_to)
return date_from, date_to