diff --git a/membership_variable_period/README.rst b/membership_variable_period/README.rst index 57733312..d291b421 100644 --- a/membership_variable_period/README.rst +++ b/membership_variable_period/README.rst @@ -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. diff --git a/membership_variable_period/models/__init__.py b/membership_variable_period/models/__init__.py index fb031c59..d4531c08 100644 --- a/membership_variable_period/models/__init__.py +++ b/membership_variable_period/models/__init__.py @@ -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 diff --git a/membership_variable_period/models/account_move_line.py b/membership_variable_period/models/account_move_line.py index eb69d462..930109d1 100644 --- a/membership_variable_period/models/account_move_line.py +++ b/membership_variable_period/models/account_move_line.py @@ -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 diff --git a/membership_variable_period/models/res_partner.py b/membership_variable_period/models/res_partner.py new file mode 100644 index 00000000..700ded35 --- /dev/null +++ b/membership_variable_period/models/res_partner.py @@ -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): 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) diff --git a/membership_variable_period/readme/DESCRIPTION.rst b/membership_variable_period/readme/DESCRIPTION.rst index a46a8e60..cf5e3f0d 100644 --- a/membership_variable_period/readme/DESCRIPTION.rst +++ b/membership_variable_period/readme/DESCRIPTION.rst @@ -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. diff --git a/membership_variable_period/static/description/index.html b/membership_variable_period/static/description/index.html index e748f6f0..90a832ae 100644 --- a/membership_variable_period/static/description/index.html +++ b/membership_variable_period/static/description/index.html @@ -379,6 +379,9 @@

Variable period for memberships

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.

diff --git a/membership_variable_period/tests/test_membership_variable_period.py b/membership_variable_period/tests/test_membership_variable_period.py index 39ba2725..e0f4443c 100644 --- a/membership_variable_period/tests/test_membership_variable_period.py +++ b/membership_variable_period/tests/test_membership_variable_period.py @@ -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 @@ -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", @@ -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() @@ -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)