create monthly recurring membership lines
This commit is contained in:
parent
a0ba3426c8
commit
8798f321b3
7 changed files with 222 additions and 0 deletions
3
membership_monthly_invoicing/__init__.py
Normal file
3
membership_monthly_invoicing/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||||
|
|
||||||
|
from . import models
|
||||||
15
membership_monthly_invoicing/__manifest__.py
Normal file
15
membership_monthly_invoicing/__manifest__.py
Normal file
|
|
@ -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",
|
||||||
|
],
|
||||||
|
}
|
||||||
18
membership_monthly_invoicing/data/ir_cron.xml
Normal file
18
membership_monthly_invoicing/data/ir_cron.xml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<record id="ir_cron_membership_monthly_invoicing" model="ir.cron">
|
||||||
|
<field name="name">Membership: Create Monthly Invoices</field>
|
||||||
|
<field name="model_id" ref="base.model_res_partner"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_create_monthly_membership_invoices()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">months</field>
|
||||||
|
<field name="nextcall" eval="(DateTime.now() + relativedelta(months=1, day=1, hour=2, minute=0, second=0))"/>
|
||||||
|
<field name="active">True</field>
|
||||||
|
<field name="user_id" ref="base.user_root"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
3
membership_monthly_invoicing/models/__init__.py
Normal file
3
membership_monthly_invoicing/models/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||||
|
|
||||||
|
from . import res_config_settings, res_partner
|
||||||
17
membership_monthly_invoicing/models/res_config_settings.py
Normal file
17
membership_monthly_invoicing/models/res_config_settings.py
Normal 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.",
|
||||||
|
)
|
||||||
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,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record id="res_config_settings_view_form_membership_monthly_invoicing" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.inherit.membership.monthly.invoicing</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="priority" eval="91"/>
|
||||||
|
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//form" position="inside">
|
||||||
|
<app data-string="Members" id="membership_monthly_invoicing"
|
||||||
|
string="Members" name="membership">
|
||||||
|
<block title="Monthly Membership Invoicing"
|
||||||
|
id="membership_monthly_invoicing_block">
|
||||||
|
<setting
|
||||||
|
string="Automatic monthly invoicing"
|
||||||
|
help="A scheduled action creates a draft membership invoice each month for every active member, copying the fee from their last membership line.">
|
||||||
|
<field name="membership_monthly_invoicing_enabled"/>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
|
</app>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue