create monthly recurring membership lines
This commit is contained in:
parent
a0ba3426c8
commit
8798f321b3
7 changed files with 222 additions and 0 deletions
140
membership_monthly_invoicing/models/res_partner.py
Normal file
140
membership_monthly_invoicing/models/res_partner.py
Normal 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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue