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>
78 lines
3.3 KiB
Python
78 lines
3.3 KiB
Python
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')
|