create monthly recurring membership lines

This commit is contained in:
GitHub Copilot 2026-06-02 14:22:13 +02:00
parent a0ba3426c8
commit 8798f321b3
7 changed files with 222 additions and 0 deletions

View file

@ -0,0 +1,3 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import res_config_settings, res_partner

View file

@ -0,0 +1,17 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields
from odoo import models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
membership_monthly_invoicing_enabled = fields.Boolean(
string="Automatic monthly membership invoicing",
config_parameter="membership_monthly_invoicing.enabled",
default=True,
help="When enabled, a scheduled action creates a draft membership "
"invoice each month for every active member, copying the fee from "
"their last membership line.",
)

View file

@ -0,0 +1,140 @@
# 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,
)