#2 #3 add membership_payment_date: membership start date -> payment date. Don't overlap membership periods on renewals

This commit is contained in:
Luis 2026-07-14 11:56:57 +02:00
parent 54965d483f
commit d8cd83bdaf
12 changed files with 511 additions and 0 deletions

View file

@ -0,0 +1 @@
from . import account_move

View file

@ -0,0 +1,122 @@
# 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, 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 reconciled payment.
If there are no payments (e.g. zero-amount invoice), fallback to today.
"""
self.ensure_one()
payments = invoice.reconciled_payment_ids
if payments:
return max(payments.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