metatroncubeswdev e5d90a83d4 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>
2026-08-17 20:08:26 -04:00

135 lines
5.5 KiB
Python

from dateutil.relativedelta import relativedelta
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):
_inherit = 'res.partner'
membership_member_id = fields.Char(
string='Member ID', copy=False, index=True, readonly=True,
)
membership_tier_id = fields.Many2one('community.membership.tier', string='Membership Tier')
membership_state = fields.Selection(
[
('none', 'Not a Member'),
('invoiced', 'Invoiced'),
('active', 'Active'),
('renewal_due', 'Renewal Due'),
('expired', 'Expired'),
],
default='none', copy=False, tracking=True,
)
membership_start = fields.Date(copy=False)
membership_expiry = fields.Date(copy=False)
family_head_id = fields.Many2one(
'res.partner', string='Family Head',
domain=[('membership_tier_id', '!=', False)],
help="If this member belongs to a family membership, the primary member it is grouped under.",
)
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."""
today = fields.Date.context_today(self)
for partner in self:
if not partner.membership_tier_id:
continue
vals = {'membership_state': 'active'}
if not partner.membership_member_id:
vals['membership_member_id'] = partner._generate_member_id()
if not partner.membership_start:
vals['membership_start'] = today
duration_days = partner.membership_tier_id.duration_days()
if duration_days:
vals['membership_expiry'] = today + relativedelta(days=duration_days)
partner.write(vals)
return True
def _generate_member_id(self):
self.ensure_one()
get_param = self.env['ir.config_parameter'].sudo().get_param
member_id_format = get_param('community_membership.member_id_format', DEFAULT_MEMBER_ID_FORMAT)
seq_number = self.env['ir.sequence'].next_by_code(MEMBER_ID_SEQUENCE_CODE) or '1'
year = fields.Date.context_today(self).year
return (
member_id_format
.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,
})],
})