# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import calendar import logging from datetime import date from dateutil.relativedelta import relativedelta from odoo import fields from odoo import models _logger = logging.getLogger(__name__) def _month_bounds(day): """Return ``(first_day, last_day)`` of the calendar month containing ``day``.""" first_day = day.replace(day=1) last_day = day.replace(day=calendar.monthrange(day.year, day.month)[1]) return first_day, last_day class ResPartner(models.Model): _inherit = "res.partner" def _cron_create_monthly_membership_invoices(self): """Monthly cron: ensure every member has a membership line for each month up to the current one, with no missing month in between. For each active member it backfills from the month following their last covered period up to the current calendar month, creating one draft membership invoice per missing month and copying the fee from their last membership line. Each line's period is forced to the calendar month it covers (day 1 to last day), independent of the previous subscription period or the product's fixed dates. The membership module is invoice-driven: creating the invoice (via ``create_membership_invoice``) is what auto-creates the ``membership.membership_line`` through the ``account.move.line`` override. """ enabled = ( self.env["ir.config_parameter"] .sudo() .get_param("membership_monthly_invoicing.enabled", "True") ) if enabled in ("False", "0", "", None): _logger.info("membership_monthly_invoicing: disabled, skipping run.") return today = fields.Date.today() current_month_start = today.replace(day=1) membership_line = self.env["membership.membership_line"] # Candidates: members with history, not cancelled. free_member would raise # in create_membership_invoice, and associate_member inherit their host's # state, so both are excluded here. partners = self.search( [ ("membership_state", "in", ("paid", "invoiced", "waiting", "old")), ("free_member", "=", False), ("associate_member", "=", False), ("member_lines", "!=", False), ] ) created = 0 skipped = 0 for partner in partners: lines = partner.member_lines # Template line for product/fee: the most recent period. last_line = lines.sorted( key=lambda line: (line.date_from or date.min, line.id) )[-1] product = last_line.membership_id if not product: skipped += 1 continue amount = last_line.member_price or product.list_price # Coverage end: latest period end across all existing lines. Backfill # starts the month after it, but never later than the current month. coverage_end = max( ( line.date_to or line.date_from for line in lines if (line.date_to or line.date_from) ), default=None, ) if coverage_end: month_cursor = coverage_end.replace(day=1) + relativedelta(months=1) else: month_cursor = current_month_start if month_cursor > current_month_start: # Already covered up to (or beyond) the current month. skipped += 1 continue while month_cursor <= current_month_start: first_day, last_day = _month_bounds(month_cursor) month_cursor += relativedelta(months=1) # Idempotency / gap-safety: skip months already covered by a line. if membership_line.search_count( [ ("partner", "=", partner.id), ("date_from", "<=", last_day), ("date_to", ">=", first_day), ] ): continue try: move = partner.create_membership_invoice(product, amount) except Exception: _logger.exception( "membership_monthly_invoicing: failed to invoice partner " "%d (%s) for %s", partner.id, partner.name, first_day, ) continue # Date the draft invoice in the month it covers, and force the # membership line period to that calendar month (overriding the # product's fixed dates set by the membership override). move.invoice_date = first_day membership_line.search( [("account_invoice_line", "in", move.invoice_line_ids.ids)] ).write({"date_from": first_day, "date_to": last_day}) created += 1 _logger.info( "membership_monthly_invoicing: %d draft invoice(s) created, " "%d member(s) already up to date (through %s).", created, skipped, current_month_start, )