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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+