feat(community_membership): renewal automation - cron, templates, draft invoices (Session 1-B)
Adds the daily membership renewal cron (_cron_process_membership_renewals):
sends a "renewal upcoming" reminder at each non-final configured offset,
flips state to renewal_due and sends a "renewal due" notice at the final
(smallest) offset, and on expiry flips to 'expired', sends an expiry notice,
and creates a draft renewal invoice for the member's tier product.
Email templates (renewal_upcoming, renewal_due, membership_expired) use
Odoo 19's current mail.template syntax ({{ }} subject/QWeb t-out body) -
the plan referenced the older ${object...} syntax, which v19 no longer
uses. Org name is pulled via a new non-stored res.partner.membership_org_name
compute field backed by ir.config_parameter, never hardcoded.
Also had to adapt the ir.cron data record: Odoo 19 removed 'numbercall'
entirely (ir.cron now delegates most fields to a linked ir.actions.server
under the hood, though name/model_id/state/code are still settable
directly on the record, per the core mail module's own cron definitions).
Verified against a live Odoo 19 + Postgres 16 container: module upgrades
clean, all 8 tests pass (4 from 1-A + 4 new: non-final-offset reminder,
final-offset state flip to renewal_due, expiry creates a draft invoice +
notice, no action outside any offset window).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
ae324c30f9
commit
e5d90a83d4
@ -33,6 +33,8 @@ configuration record managed by the deployment layer.
|
||||
'security/membership_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/ir_sequence.xml',
|
||||
'data/mail_templates.xml',
|
||||
'data/ir_cron.xml',
|
||||
'views/membership_tier_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
|
||||
14
addons/community_membership/data/ir_cron.xml
Normal file
14
addons/community_membership/data/ir_cron.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="ir_cron_membership_renewals" model="ir.cron">
|
||||
<field name="name">Membership: Process Renewals</field>
|
||||
<field name="model_id" ref="base.model_res_partner"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_process_membership_renewals()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
69
addons/community_membership/data/mail_templates.xml
Normal file
69
addons/community_membership/data/mail_templates.xml
Normal file
@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<record id="mail_template_membership_renewal_upcoming" model="mail.template">
|
||||
<field name="name">Membership: Renewal Upcoming</field>
|
||||
<field name="model_id" ref="base.model_res_partner"/>
|
||||
<field name="subject">{{ object.membership_org_name or 'Your organization' }} membership renewal reminder</field>
|
||||
<field name="partner_to">{{ object.id }}</field>
|
||||
<field name="lang">{{ object.lang }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
<field name="body_html" type="html">
|
||||
<div style="margin: 0px; padding: 0px; font-size: 13px;">
|
||||
<p>Dear <t t-out="object.name or ''">Member</t>,</p>
|
||||
<p>
|
||||
Your membership
|
||||
<t t-if="object.membership_member_id">(<t t-out="object.membership_member_id"/>)</t>
|
||||
with <t t-out="object.membership_org_name or 'us'"/> is due for renewal on
|
||||
<t t-out="format_date(object.membership_expiry)"/>.
|
||||
</p>
|
||||
<p>No action is needed yet - this is an early reminder. You will receive another notice closer to the date.</p>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="mail_template_membership_renewal_due" model="mail.template">
|
||||
<field name="name">Membership: Renewal Due</field>
|
||||
<field name="model_id" ref="base.model_res_partner"/>
|
||||
<field name="subject">{{ object.membership_org_name or 'Your organization' }} membership renewal is due</field>
|
||||
<field name="partner_to">{{ object.id }}</field>
|
||||
<field name="lang">{{ object.lang }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
<field name="body_html" type="html">
|
||||
<div style="margin: 0px; padding: 0px; font-size: 13px;">
|
||||
<p>Dear <t t-out="object.name or ''">Member</t>,</p>
|
||||
<p>
|
||||
Your membership
|
||||
<t t-if="object.membership_member_id">(<t t-out="object.membership_member_id"/>)</t>
|
||||
with <t t-out="object.membership_org_name or 'us'"/> expires on
|
||||
<t t-out="format_date(object.membership_expiry)"/>.
|
||||
</p>
|
||||
<p>Please renew soon to keep your membership active and avoid a lapse in benefits.</p>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="mail_template_membership_expired" model="mail.template">
|
||||
<field name="name">Membership: Expired</field>
|
||||
<field name="model_id" ref="base.model_res_partner"/>
|
||||
<field name="subject">Your {{ object.membership_org_name or 'organization' }} membership has expired</field>
|
||||
<field name="partner_to">{{ object.id }}</field>
|
||||
<field name="lang">{{ object.lang }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
<field name="body_html" type="html">
|
||||
<div style="margin: 0px; padding: 0px; font-size: 13px;">
|
||||
<p>Dear <t t-out="object.name or ''">Member</t>,</p>
|
||||
<p>
|
||||
Your membership
|
||||
<t t-if="object.membership_member_id">(<t t-out="object.membership_member_id"/>)</t>
|
||||
with <t t-out="object.membership_org_name or 'us'"/> expired on
|
||||
<t t-out="format_date(object.membership_expiry)"/>.
|
||||
</p>
|
||||
<p>A renewal invoice has been prepared for you. Please get in touch to reactivate your membership.</p>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
@ -4,6 +4,7 @@ from odoo import api, fields, models
|
||||
|
||||
MEMBER_ID_SEQUENCE_CODE = 'community.membership.member_id'
|
||||
DEFAULT_MEMBER_ID_FORMAT = 'MEM-{year}-{seq}'
|
||||
DEFAULT_RENEWAL_OFFSETS = '30,14,7'
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
@ -32,6 +33,12 @@ class ResPartner(models.Model):
|
||||
)
|
||||
is_volunteer = fields.Boolean(string='Volunteer')
|
||||
volunteer_hours = fields.Float(string='Volunteer Hours')
|
||||
membership_org_name = fields.Char(compute='_compute_membership_org_name')
|
||||
|
||||
def _compute_membership_org_name(self):
|
||||
org_name = self.env['ir.config_parameter'].sudo().get_param('community_membership.org_name') or ''
|
||||
for partner in self:
|
||||
partner.membership_org_name = org_name
|
||||
|
||||
def action_activate_membership(self):
|
||||
"""Transition to 'active', generating a member ID and expiry if needed."""
|
||||
@ -61,3 +68,67 @@ class ResPartner(models.Model):
|
||||
.replace('{year}', str(year))
|
||||
.replace('{seq}', seq_number)
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _get_renewal_offsets(self):
|
||||
"""Configured days-before-expiry reminder offsets, sorted descending."""
|
||||
raw = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'community_membership.renewal_offsets', DEFAULT_RENEWAL_OFFSETS
|
||||
)
|
||||
offsets = []
|
||||
for chunk in (raw or '').split(','):
|
||||
chunk = chunk.strip()
|
||||
if chunk.isdigit():
|
||||
offsets.append(int(chunk))
|
||||
return sorted(set(offsets), reverse=True) or [int(d) for d in DEFAULT_RENEWAL_OFFSETS.split(',')]
|
||||
|
||||
@api.model
|
||||
def _cron_process_membership_renewals(self):
|
||||
"""Daily job: send renewal reminders, flip state at the final offset, expire past due."""
|
||||
today = fields.Date.context_today(self)
|
||||
offsets = self._get_renewal_offsets()
|
||||
final_offset = min(offsets)
|
||||
|
||||
candidates = self.search([
|
||||
('membership_expiry', '!=', False),
|
||||
('membership_state', 'in', ['active', 'renewal_due']),
|
||||
])
|
||||
for partner in candidates:
|
||||
days_left = (partner.membership_expiry - today).days
|
||||
|
||||
if days_left < 0:
|
||||
partner.write({'membership_state': 'expired'})
|
||||
partner._send_membership_mail('community_membership.mail_template_membership_expired')
|
||||
partner._create_renewal_invoice()
|
||||
continue
|
||||
|
||||
if partner.membership_state == 'active' and days_left == final_offset:
|
||||
partner.write({'membership_state': 'renewal_due'})
|
||||
partner._send_membership_mail('community_membership.mail_template_membership_renewal_due')
|
||||
elif partner.membership_state == 'active' and days_left in offsets:
|
||||
partner._send_membership_mail('community_membership.mail_template_membership_renewal_upcoming')
|
||||
|
||||
return True
|
||||
|
||||
def _send_membership_mail(self, template_xmlid):
|
||||
self.ensure_one()
|
||||
template = self.env.ref(template_xmlid, raise_if_not_found=False)
|
||||
if template:
|
||||
template.send_mail(self.id, force_send=False)
|
||||
|
||||
def _create_renewal_invoice(self):
|
||||
"""Create a draft renewal invoice for the member's tier product, for admin review."""
|
||||
self.ensure_one()
|
||||
tier = self.membership_tier_id
|
||||
if not tier or not tier.product_id:
|
||||
return self.env['account.move']
|
||||
return self.env['account.move'].create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': self.id,
|
||||
'invoice_origin': 'Membership Renewal',
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'product_id': tier.product_id.id,
|
||||
'quantity': 1,
|
||||
'price_unit': tier.price,
|
||||
})],
|
||||
})
|
||||
|
||||
@ -1 +1,2 @@
|
||||
from . import test_membership
|
||||
from . import test_membership_renewal
|
||||
|
||||
77
addons/community_membership/tests/test_membership_renewal.py
Normal file
77
addons/community_membership/tests/test_membership_renewal.py
Normal file
@ -0,0 +1,77 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import fields
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestMembershipRenewal(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.env['ir.config_parameter'].sudo().set_param('community_membership.renewal_offsets', '30,14,7')
|
||||
self.tier = self.env['community.membership.tier'].create({
|
||||
'name': 'Individual',
|
||||
'code': 'RENEW-IND',
|
||||
'price': 50.0,
|
||||
'period': 'annual',
|
||||
})
|
||||
self.partner = self.env['res.partner'].create({
|
||||
'name': 'Renewal Tester',
|
||||
'email': 'renewal.tester@example.com',
|
||||
'membership_tier_id': self.tier.id,
|
||||
})
|
||||
self.partner.action_activate_membership()
|
||||
|
||||
def _set_days_to_expiry(self, days):
|
||||
today = fields.Date.context_today(self.partner)
|
||||
self.partner.membership_expiry = today + timedelta(days=days)
|
||||
|
||||
def test_reminder_sent_at_non_final_offset(self):
|
||||
self._set_days_to_expiry(14)
|
||||
mail_count_before = self.env['mail.mail'].search_count([])
|
||||
|
||||
self.env['res.partner']._cron_process_membership_renewals()
|
||||
|
||||
self.partner.invalidate_recordset()
|
||||
self.assertEqual(self.partner.membership_state, 'active', "Non-final offset should not change state")
|
||||
mail_count_after = self.env['mail.mail'].search_count([])
|
||||
self.assertGreater(mail_count_after, mail_count_before, "A renewal_upcoming reminder should be queued")
|
||||
|
||||
def test_state_flips_to_renewal_due_at_final_offset(self):
|
||||
self._set_days_to_expiry(7)
|
||||
mail_count_before = self.env['mail.mail'].search_count([])
|
||||
|
||||
self.env['res.partner']._cron_process_membership_renewals()
|
||||
|
||||
self.partner.invalidate_recordset()
|
||||
self.assertEqual(self.partner.membership_state, 'renewal_due')
|
||||
mail_count_after = self.env['mail.mail'].search_count([])
|
||||
self.assertGreater(mail_count_after, mail_count_before, "A renewal_due notice should be queued")
|
||||
|
||||
def test_expiry_creates_draft_invoice_and_sends_mail(self):
|
||||
self._set_days_to_expiry(-1)
|
||||
invoice_count_before = self.env['account.move'].search_count([
|
||||
('partner_id', '=', self.partner.id), ('move_type', '=', 'out_invoice'),
|
||||
])
|
||||
mail_count_before = self.env['mail.mail'].search_count([])
|
||||
|
||||
self.env['res.partner']._cron_process_membership_renewals()
|
||||
|
||||
self.partner.invalidate_recordset()
|
||||
self.assertEqual(self.partner.membership_state, 'expired')
|
||||
|
||||
invoices = self.env['account.move'].search([
|
||||
('partner_id', '=', self.partner.id), ('move_type', '=', 'out_invoice'),
|
||||
])
|
||||
self.assertEqual(len(invoices), invoice_count_before + 1)
|
||||
self.assertEqual(invoices[-1].state, 'draft', "Renewal invoice should be left as draft for review")
|
||||
|
||||
mail_count_after = self.env['mail.mail'].search_count([])
|
||||
self.assertGreater(mail_count_after, mail_count_before, "An expiry notice should be queued")
|
||||
|
||||
def test_no_action_outside_offsets(self):
|
||||
self._set_days_to_expiry(20)
|
||||
self.env['res.partner']._cron_process_membership_renewals()
|
||||
self.partner.invalidate_recordset()
|
||||
self.assertEqual(self.partner.membership_state, 'active')
|
||||
Loading…
x
Reference in New Issue
Block a user