From 8798f321b36b105e11b9bb7f19c80970237d6cf9 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Tue, 2 Jun 2026 14:22:13 +0200 Subject: [PATCH] create monthly recurring membership lines --- membership_monthly_invoicing/__init__.py | 3 + membership_monthly_invoicing/__manifest__.py | 15 ++ membership_monthly_invoicing/data/ir_cron.xml | 18 +++ .../models/__init__.py | 3 + .../models/res_config_settings.py | 17 +++ .../models/res_partner.py | 140 ++++++++++++++++++ .../views/res_config_settings_views.xml | 26 ++++ 7 files changed, 222 insertions(+) create mode 100644 membership_monthly_invoicing/__init__.py create mode 100644 membership_monthly_invoicing/__manifest__.py create mode 100644 membership_monthly_invoicing/data/ir_cron.xml create mode 100644 membership_monthly_invoicing/models/__init__.py create mode 100644 membership_monthly_invoicing/models/res_config_settings.py create mode 100644 membership_monthly_invoicing/models/res_partner.py create mode 100644 membership_monthly_invoicing/views/res_config_settings_views.xml diff --git a/membership_monthly_invoicing/__init__.py b/membership_monthly_invoicing/__init__.py new file mode 100644 index 0000000..69f7bab --- /dev/null +++ b/membership_monthly_invoicing/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/membership_monthly_invoicing/__manifest__.py b/membership_monthly_invoicing/__manifest__.py new file mode 100644 index 0000000..ca69def --- /dev/null +++ b/membership_monthly_invoicing/__manifest__.py @@ -0,0 +1,15 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Membership Monthly Invoicing", + "summary": "Automatically creates a monthly membership invoice for each " + "active member, copying the fee from their last membership line.", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Custom", + "depends": ["membership"], + "data": [ + "data/ir_cron.xml", + "views/res_config_settings_views.xml", + ], +} diff --git a/membership_monthly_invoicing/data/ir_cron.xml b/membership_monthly_invoicing/data/ir_cron.xml new file mode 100644 index 0000000..dc36f41 --- /dev/null +++ b/membership_monthly_invoicing/data/ir_cron.xml @@ -0,0 +1,18 @@ + + + + + + Membership: Create Monthly Invoices + + code + model._cron_create_monthly_membership_invoices() + 1 + months + + True + + + + + diff --git a/membership_monthly_invoicing/models/__init__.py b/membership_monthly_invoicing/models/__init__.py new file mode 100644 index 0000000..f1d9ae6 --- /dev/null +++ b/membership_monthly_invoicing/models/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import res_config_settings, res_partner diff --git a/membership_monthly_invoicing/models/res_config_settings.py b/membership_monthly_invoicing/models/res_config_settings.py new file mode 100644 index 0000000..3da9c8b --- /dev/null +++ b/membership_monthly_invoicing/models/res_config_settings.py @@ -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.", + ) diff --git a/membership_monthly_invoicing/models/res_partner.py b/membership_monthly_invoicing/models/res_partner.py new file mode 100644 index 0000000..37d6a19 --- /dev/null +++ b/membership_monthly_invoicing/models/res_partner.py @@ -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, + ) diff --git a/membership_monthly_invoicing/views/res_config_settings_views.xml b/membership_monthly_invoicing/views/res_config_settings_views.xml new file mode 100644 index 0000000..1027109 --- /dev/null +++ b/membership_monthly_invoicing/views/res_config_settings_views.xml @@ -0,0 +1,26 @@ + + + + + res.config.settings.view.form.inherit.membership.monthly.invoicing + res.config.settings + + + + + + + + + + + + + + + +