From e5d90a83d4ee05a01f16d31b47009a46bce4c6ce Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Mon, 17 Aug 2026 20:08:26 -0400 Subject: [PATCH] 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 --- addons/community_membership/__manifest__.py | 2 + addons/community_membership/data/ir_cron.xml | 14 ++++ .../data/mail_templates.xml | 69 +++++++++++++++++ .../models/res_partner.py | 71 +++++++++++++++++ addons/community_membership/tests/__init__.py | 1 + .../tests/test_membership_renewal.py | 77 +++++++++++++++++++ 6 files changed, 234 insertions(+) create mode 100644 addons/community_membership/data/ir_cron.xml create mode 100644 addons/community_membership/data/mail_templates.xml create mode 100644 addons/community_membership/tests/test_membership_renewal.py diff --git a/addons/community_membership/__manifest__.py b/addons/community_membership/__manifest__.py index d3d1c80..13f3d85 100644 --- a/addons/community_membership/__manifest__.py +++ b/addons/community_membership/__manifest__.py @@ -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', diff --git a/addons/community_membership/data/ir_cron.xml b/addons/community_membership/data/ir_cron.xml new file mode 100644 index 0000000..0846f9a --- /dev/null +++ b/addons/community_membership/data/ir_cron.xml @@ -0,0 +1,14 @@ + + + + + Membership: Process Renewals + + code + model._cron_process_membership_renewals() + 1 + days + + + + diff --git a/addons/community_membership/data/mail_templates.xml b/addons/community_membership/data/mail_templates.xml new file mode 100644 index 0000000..77f6f43 --- /dev/null +++ b/addons/community_membership/data/mail_templates.xml @@ -0,0 +1,69 @@ + + + + + + Membership: Renewal Upcoming + + {{ object.membership_org_name or 'Your organization' }} membership renewal reminder + {{ object.id }} + {{ object.lang }} + + +
+

Dear Member,

+

+ Your membership + () + with is due for renewal on + . +

+

No action is needed yet - this is an early reminder. You will receive another notice closer to the date.

+
+
+
+ + + Membership: Renewal Due + + {{ object.membership_org_name or 'Your organization' }} membership renewal is due + {{ object.id }} + {{ object.lang }} + + +
+

Dear Member,

+

+ Your membership + () + with expires on + . +

+

Please renew soon to keep your membership active and avoid a lapse in benefits.

+
+
+
+ + + Membership: Expired + + Your {{ object.membership_org_name or 'organization' }} membership has expired + {{ object.id }} + {{ object.lang }} + + +
+

Dear Member,

+

+ Your membership + () + with expired on + . +

+

A renewal invoice has been prepared for you. Please get in touch to reactivate your membership.

+
+
+
+ +
+
diff --git a/addons/community_membership/models/res_partner.py b/addons/community_membership/models/res_partner.py index 3e28c3b..a0afcd1 100644 --- a/addons/community_membership/models/res_partner.py +++ b/addons/community_membership/models/res_partner.py @@ -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, + })], + }) diff --git a/addons/community_membership/tests/__init__.py b/addons/community_membership/tests/__init__.py index 4dad2bf..f68c4a3 100644 --- a/addons/community_membership/tests/__init__.py +++ b/addons/community_membership/tests/__init__.py @@ -1 +1,2 @@ from . import test_membership +from . import test_membership_renewal diff --git a/addons/community_membership/tests/test_membership_renewal.py b/addons/community_membership/tests/test_membership_renewal.py new file mode 100644 index 0000000..398eb21 --- /dev/null +++ b/addons/community_membership/tests/test_membership_renewal.py @@ -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')