# 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