122 lines
4.6 KiB
Python
122 lines
4.6 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, 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
|