Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions membership_variable_period/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ and adapting them for this purpose. As now the quota is not attached to a fixed
period, you can also invoice more than one quantity for being a member for
the corresponding number of periods.

A member's membership is renewed when a new variable period membership product
of the same category is purchased. The new period will begin the day after the
end of the last period.

Finally, a cron has been included that triggers the recalculation of the
membership state, allowing to have "old members", which doesn't work well
on standard.
Expand Down
1 change: 1 addition & 0 deletions membership_variable_period/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# License AGPL-3 - See https://www.gnu.org/licenses/agpl-3.0.html
from . import product_template
from . import account_move_line
from . import res_partner
16 changes: 13 additions & 3 deletions membership_variable_period/models/account_move_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,29 @@ class AccountMoveLine(models.Model):
_inherit = "account.move.line"

def _prepare_membership_line(self, move, product, price_unit, line_id, qty=1.0):
line = self.browse(line_id)
qty = int(math.ceil(qty))
date_from = move.invoice_date or fields.Date.today()
partner = (
line._get_partner_for_membership()
if hasattr(line, "_get_partner_for_membership")
else move.partner_id
)
date_from = (
partner.get_membership_renewal_date(product)
or move.invoice_date
or fields.Date.today()
)
date_to = product.product_tmpl_id._get_next_date(date_from, qty=qty)
date_to = date_to and (date_to - timedelta(days=1)) or False
return {
"partner": move.partner_id.id,
"partner": partner.id,
"membership_id": product.id,
"member_price": price_unit,
"date": move.invoice_date or fields.Date.today(),
"date_from": date_from,
"date_to": date_to,
"state": "waiting",
"account_invoice_line": line_id,
"account_invoice_line": line.id,
}

@api.model
Expand Down
41 changes: 41 additions & 0 deletions membership_variable_period/models/res_partner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from datetime import timedelta

from odoo import api, fields, models


class ResPartner(models.Model):
_inherit = "res.partner"

@api.model
def _membership_renewable_member_states(self):
"""Inherit this method to define renewable membership states.

:return tuple: list of renewable membership states
"""
return self._membership_member_states()

def get_membership_renewal_date(self, product):
"""Retrieve the renewal date of a member for a given membership product.

:param product (Model<product.product>): the affected product
:return datetime.date: the renewal date of the member
"""
self.ensure_one()
last_date_to = False
if (
product
and product.membership_type == "variable"
and product.membership_category_id in self.membership_category_ids
):
today = fields.Date.today()
last_membership = self.member_lines.filtered(
lambda line: line.category_id == product.membership_category_id
and line.state in self._membership_renewable_member_states()
and line.date_to
and line.date_to >= today
and (not line.date_cancel or line.date_cancel >= today)
).sorted("date_to", reverse=True)[:1]
last_date_to = last_membership.date_to
return last_date_to and last_date_to + timedelta(days=1)
4 changes: 4 additions & 0 deletions membership_variable_period/readme/DESCRIPTION.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ and adapting them for this purpose. As now the quota is not attached to a fixed
period, you can also invoice more than one quantity for being a member for
the corresponding number of periods.

A member's membership is renewed when a new variable period membership product
of the same category is purchased. The new period will begin the day after the
end of the last period.

Finally, a cron has been included that triggers the recalculation of the
membership state, allowing to have "old members", which doesn't work well
on standard.
3 changes: 3 additions & 0 deletions membership_variable_period/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,9 @@ <h1 class="title">Variable period for memberships</h1>
and adapting them for this purpose. As now the quota is not attached to a fixed
period, you can also invoice more than one quantity for being a member for
the corresponding number of periods.</p>
<p>A member’s membership is renewed when a new variable period membership product
of the same category is purchased. The new period will begin the day after the
end of the last period.</p>
<p>Finally, a cron has been included that triggers the recalculation of the
membership state, allowing to have “old members”, which doesn’t work well
on standard.</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
# License AGPL-3 - See https://www.gnu.org/licenses/agpl-3.0
from datetime import date

from freezegun import freeze_time

from odoo import fields
from odoo.tests import common, tagged

Expand All @@ -23,6 +25,10 @@ def setUpClass(cls):
[("visible", "=", True)], limit=1
)
coa.try_loading(company=cls.env.company, install_demo=False)
cls.category_gold = cls.env.ref("membership_extension.membership_category_gold")
cls.category_silver = cls.env.ref(
"membership_extension.membership_category_silver"
)
cls.product = cls.env["product.product"].create(
{
"name": "Membership product with variable period",
Expand All @@ -32,17 +38,28 @@ def setUpClass(cls):
"membership_interval_unit": "weeks",
}
)
cls.product_silver = cls.env["product.product"].create(
{
"name": "Silver membership product with variable period",
"membership": True,
"membership_type": "variable",
"membership_interval_qty": 1,
"membership_interval_unit": "weeks",
"membership_category_id": cls.category_silver.id,
}
)
cls.partner = cls.env["res.partner"].create({"name": "Test"})

def create_invoice(self, invoice_date, quantity=1.0):
def create_invoice(self, invoice_date, quantity=1.0, product=None):
product = product or self.product
invoice_form = common.Form(
self.env["account.move"].with_context(default_move_type="out_invoice")
)
invoice_form.invoice_date = invoice_date
invoice_form.partner_id = self.partner
with invoice_form.invoice_line_ids.new() as invoice_line_form:
invoice_line_form.product_id = self.product
invoice_line_form.price_unit = self.product.list_price
invoice_line_form.product_id = product
invoice_line_form.price_unit = product.list_price
invoice_line_form.quantity = quantity
return invoice_form.save()

Expand Down Expand Up @@ -235,3 +252,55 @@ def test_create_invoice_line_with_no_product(self):

self.assertFalse(invoice.invoice_line_ids[0].product_id)
self.assertFalse(invoice.invoice_line_ids[0].membership_lines)

@freeze_time("2025-01-01")
def test_create_membership_renewal(self):
self.product.membership_category_id = self.category_gold
for _ in range(2):
invoice = self.create_invoice("2025-01-01")
membership_line = invoice.invoice_line_ids[0].membership_lines[0]
membership_line.write({"state": "invoiced"})
self.assertEqual(
membership_line.date_from, fields.Date.from_string("2025-01-08")
)
self.assertEqual(membership_line.date_to, fields.Date.from_string("2025-01-14"))
self.assertEqual(
self.partner.membership_start, fields.Date.from_string("2025-01-01")
)
self.assertEqual(
self.partner.membership_stop, fields.Date.from_string("2025-01-14")
)

@freeze_time("2025-01-01")
def test_check_membership_non_renewal(self):
self.product.membership_category_id = self.category_gold
invoice = self.create_invoice("2024-12-01")
membership_line = invoice.invoice_line_ids[0].membership_lines[0]
membership_line.write({"state": "invoiced"})
self.assertEqual(membership_line.date_to, fields.Date.from_string("2024-12-07"))

# The previous membership has expired
invoice = self.create_invoice("2025-01-01")
membership_line = invoice.invoice_line_ids[0].membership_lines[0]
self.assertEqual(
membership_line.date_from, fields.Date.from_string("2025-01-01")
)
self.assertEqual(membership_line.state, "waiting")

# The state of the previous membership is still waiting
invoice = self.create_invoice("2025-01-01")
membership_line = invoice.invoice_line_ids[0].membership_lines[0]
membership_line.write({"state": "invoiced"})
self.assertEqual(
membership_line.date_from, fields.Date.from_string("2025-01-01")
)
self.assertEqual(self.partner.membership_category_ids, self.category_gold)

# The previous membership has a different category
invoice = self.create_invoice("2025-01-01", product=self.product_silver)
membership_line = invoice.invoice_line_ids[0].membership_lines[0]
membership_line.write({"state": "invoiced"})
self.assertEqual(
membership_line.date_from, fields.Date.from_string("2025-01-01")
)
self.assertTrue(self.category_silver in self.partner.membership_category_ids)