140 lines
5.4 KiB
Python
140 lines
5.4 KiB
Python
# 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,
|
|
)
|