diff --git a/membership_subscription/__manifest__.py b/membership_subscription/__manifest__.py index 557804f8..e974f534 100644 --- a/membership_subscription/__manifest__.py +++ b/membership_subscription/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Membership Subscription", - "version": "16.0.1.0.0", + "version": "16.0.1.1.0", "license": "AGPL-3", "category": "Association", "website": "https://www.onestein.nl", diff --git a/membership_subscription/models/product_template.py b/membership_subscription/models/product_template.py index 1a642003..359de81a 100644 --- a/membership_subscription/models/product_template.py +++ b/membership_subscription/models/product_template.py @@ -5,6 +5,10 @@ class ProductTemplate(models.Model): _inherit = "product.template" first_of_period_billing_policy = fields.Boolean("Billing Policy: First of Period") + membership_skip_invoice = fields.Boolean( + string="Skip Invoice", + help="Skip invoice for this product when sold as a subscription", + ) @api.onchange("membership_type") def _onchange_membership_type(self): diff --git a/membership_subscription/models/sale_subscription.py b/membership_subscription/models/sale_subscription.py index 02c0e4bb..a3459e55 100644 --- a/membership_subscription/models/sale_subscription.py +++ b/membership_subscription/models/sale_subscription.py @@ -2,17 +2,20 @@ from dateutil.relativedelta import relativedelta -from odoo import models +from odoo import fields, models class SaleSubscription(models.Model): _inherit = "sale.subscription" def calculate_recurring_next_date(self, start_date): - if self.account_invoice_ids_count == 0: + lines = self.sale_subscription_line_ids + if self.account_invoice_ids_count == 0 and not lines.product_id.filtered( + lambda x: x.membership_skip_invoice + ): self.recurring_next_date = date.today() else: - if self.sale_subscription_line_ids.mapped("product_id").filtered( + if lines.product_id.filtered( lambda x: x.first_of_period_billing_policy and x.membership_type == "variable" ): @@ -36,3 +39,58 @@ def calculate_recurring_next_date(self, start_date): return super(SaleSubscription, self).calculate_recurring_next_date( start_date ) + + def _generate_subscription_date_range(self): + self.ensure_one() + if self._membership_skip_invoice(): + start_date = self.recurring_next_date or self.date_start + end_date = self._calculate_recurring_next_date(start_date) + return start_date, end_date + return super()._generate_subscription_date_range() + + def generate_invoice(self): + if self._membership_skip_invoice(): + self._membership_generate_membership_lines() + return + return super(SaleSubscription, self).generate_invoice() + + def create_invoice(self): + if self._membership_skip_invoice(): + self._membership_generate_membership_lines() + return + return super(SaleSubscription, self).create_invoice() + + def manual_invoice(self): + if self._membership_skip_invoice(): + self._membership_generate_membership_lines() + return + + def _membership_skip_invoice(self): + return bool( + self.sale_subscription_line_ids.filtered( + lambda x: x.product_id.membership + and x.product_id.membership_skip_invoice + ) + ) + + def _membership_generate_membership_lines(self): + memberships_vals = [] + for line in self.sale_subscription_line_ids.filtered( + lambda x: x.product_id.membership and x.product_id.membership_skip_invoice + ): + subscription = line.sale_subscription_id + date_from, date_to = subscription._generate_subscription_date_range() + memberships_vals.append( + { + "partner": subscription.partner_id.id, + "membership_id": line.product_id.id, + "member_price": line.price_unit, + "date": fields.Date.today(), + "date_from": date_from, + "date_to": date_to, + "state": "free", + } + ) + subscription.calculate_recurring_next_date(subscription.recurring_next_date) + + self.env["membership.membership_line"].create(memberships_vals) diff --git a/membership_subscription/tests/__init__.py b/membership_subscription/tests/__init__.py new file mode 100644 index 00000000..0b952c75 --- /dev/null +++ b/membership_subscription/tests/__init__.py @@ -0,0 +1 @@ +from . import test_membership_skip_invoice diff --git a/membership_subscription/tests/test_membership_skip_invoice.py b/membership_subscription/tests/test_membership_skip_invoice.py new file mode 100644 index 00000000..e111b692 --- /dev/null +++ b/membership_subscription/tests/test_membership_skip_invoice.py @@ -0,0 +1,416 @@ +from datetime import date + +from dateutil.relativedelta import relativedelta + +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestMembershipSkipInvoice(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create( + { + "name": "Test Customer", + "email": "test@example.com", + } + ) + + cls.pricelist = cls.env["product.pricelist"].create( + { + "name": "Test Pricelist", + "currency_id": cls.env.company.currency_id.id, + } + ) + + cls.stage = cls.env.ref("subscription_oca.subscription_stage_in_progress") + + cls.subscription_template = cls.env["sale.subscription.template"].create( + { + "name": "Monthly Membership Template", + "recurring_rule_type": "months", + "recurring_interval": 1, + } + ) + + cls.product_membership_skip = cls.env["product.product"].create( + { + "name": "Membership Product - Skip Invoice", + "type": "service", + "list_price": 0, + "membership": True, + "membership_skip_invoice": True, + "subscribable": True, + "subscription_template_id": cls.subscription_template.id, + } + ) + + cls.product_membership_normal = cls.env["product.product"].create( + { + "name": "Membership Product - Normal", + "type": "service", + "list_price": 50.0, + "membership": True, + "membership_skip_invoice": False, + "subscribable": True, + "subscription_template_id": cls.subscription_template.id, + } + ) + + def test_membership_skip_invoice_detection(self): + """Test that _membership_skip_invoice correctly detects products with skip invoice flag""" + subscription = self.env["sale.subscription"].create( + { + "partner_id": self.partner.id, + "template_id": self.subscription_template.id, + "date_start": date.today(), + "pricelist_id": self.pricelist.id, + } + ) + + self.env["sale.subscription.line"].create( + { + "sale_subscription_id": subscription.id, + "product_id": self.product_membership_normal.id, + "name": "Normal Membership", + "price_unit": 50.0, + "product_uom_qty": 1.0, + } + ) + + self.assertFalse(subscription._membership_skip_invoice()) + + self.env["sale.subscription.line"].create( + { + "sale_subscription_id": subscription.id, + "product_id": self.product_membership_skip.id, + "name": "Skip Invoice Membership", + "price_unit": 0, + "product_uom_qty": 1.0, + } + ) + + self.assertTrue(subscription._membership_skip_invoice()) + + def test_generate_invoice_skip(self): + """Test that generate_invoice skips invoice creation and creates membership lines instead""" + subscription = self.env["sale.subscription"].create( + { + "partner_id": self.partner.id, + "template_id": self.subscription_template.id, + "date_start": date.today(), + "recurring_next_date": date.today(), + "pricelist_id": self.pricelist.id, + } + ) + + self.env["sale.subscription.line"].create( + { + "sale_subscription_id": subscription.id, + "product_id": self.product_membership_skip.id, + "name": "Skip Invoice Membership", + "price_unit": 0, + "product_uom_qty": 1.0, + } + ) + + initial_invoice_count = self.env["account.move"].search_count( + [("partner_id", "=", self.partner.id)] + ) + initial_membership_count = self.env["membership.membership_line"].search_count( + [("partner", "=", self.partner.id)] + ) + + subscription.generate_invoice() + + final_invoice_count = self.env["account.move"].search_count( + [("partner_id", "=", self.partner.id)] + ) + final_membership_count = self.env["membership.membership_line"].search_count( + [("partner", "=", self.partner.id)] + ) + + self.assertEqual(initial_invoice_count, final_invoice_count) + self.assertEqual(final_membership_count, initial_membership_count + 1) + + membership_line = self.env["membership.membership_line"].search( + [("partner", "=", self.partner.id)], limit=1 + ) + self.assertEqual(membership_line.membership_id, self.product_membership_skip) + self.assertEqual(membership_line.member_price, 0) + self.assertEqual(membership_line.state, "free") + + def test_create_invoice_skip(self): + """Test that create_invoice skips invoice creation for skip invoice products""" + subscription = self.env["sale.subscription"].create( + { + "partner_id": self.partner.id, + "template_id": self.subscription_template.id, + "date_start": date.today(), + "recurring_next_date": date.today(), + "pricelist_id": self.pricelist.id, + } + ) + + self.env["sale.subscription.line"].create( + { + "sale_subscription_id": subscription.id, + "product_id": self.product_membership_skip.id, + "name": "Skip Invoice Membership", + "price_unit": 0, + "product_uom_qty": 1.0, + } + ) + + initial_invoice_count = self.env["account.move"].search_count( + [("partner_id", "=", self.partner.id)] + ) + + result = subscription.create_invoice() + + final_invoice_count = self.env["account.move"].search_count( + [("partner_id", "=", self.partner.id)] + ) + + self.assertEqual(initial_invoice_count, final_invoice_count) + self.assertFalse(result) + + def test_manual_invoice_skip(self): + """Test that manual_invoice skips invoice creation for skip invoice products""" + subscription = self.env["sale.subscription"].create( + { + "partner_id": self.partner.id, + "template_id": self.subscription_template.id, + "date_start": date.today(), + "recurring_next_date": date.today(), + "pricelist_id": self.pricelist.id, + } + ) + + self.env["sale.subscription.line"].create( + { + "sale_subscription_id": subscription.id, + "product_id": self.product_membership_skip.id, + "name": "Skip Invoice Membership", + "price_unit": 0, + "product_uom_qty": 1.0, + } + ) + + initial_invoice_count = self.env["account.move"].search_count( + [("partner_id", "=", self.partner.id)] + ) + + subscription.manual_invoice() + + final_invoice_count = self.env["account.move"].search_count( + [("partner_id", "=", self.partner.id)] + ) + + self.assertEqual(initial_invoice_count, final_invoice_count) + + def test_membership_line_date_range(self): + """Test that membership lines are created with correct date ranges""" + start_date = date.today() + subscription = self.env["sale.subscription"].create( + { + "partner_id": self.partner.id, + "template_id": self.subscription_template.id, + "date_start": start_date, + "recurring_next_date": start_date, + "pricelist_id": self.pricelist.id, + } + ) + + self.env["sale.subscription.line"].create( + { + "sale_subscription_id": subscription.id, + "product_id": self.product_membership_skip.id, + "name": "Skip Invoice Membership", + "price_unit": 0, + "product_uom_qty": 1.0, + } + ) + + subscription.generate_invoice() + + membership_line = self.env["membership.membership_line"].search( + [("partner", "=", self.partner.id)], limit=1 + ) + + self.assertEqual(membership_line.date_from, start_date) + expected_date_to = start_date + relativedelta(months=1) + self.assertEqual(membership_line.date_to, expected_date_to) + + def test_mixed_products_invoice_behavior(self): + """Test subscription with both skip invoice and normal products""" + subscription = self.env["sale.subscription"].create( + { + "partner_id": self.partner.id, + "template_id": self.subscription_template.id, + "date_start": date.today(), + "recurring_next_date": date.today(), + "pricelist_id": self.pricelist.id, + } + ) + + self.env["sale.subscription.line"].create( + { + "sale_subscription_id": subscription.id, + "product_id": self.product_membership_normal.id, + "name": "Normal Membership", + "price_unit": 50.0, + "product_uom_qty": 1.0, + } + ) + + self.env["sale.subscription.line"].create( + { + "sale_subscription_id": subscription.id, + "product_id": self.product_membership_skip.id, + "name": "Skip Invoice Membership", + "price_unit": 100.0, + "product_uom_qty": 1.0, + } + ) + + initial_membership_count = self.env["membership.membership_line"].search_count( + [("partner", "=", self.partner.id)] + ) + + subscription.generate_invoice() + + final_membership_count = self.env["membership.membership_line"].search_count( + [("partner", "=", self.partner.id)] + ) + + self.assertEqual(final_membership_count, initial_membership_count + 1) + + membership_line = self.env["membership.membership_line"].search( + [("partner", "=", self.partner.id)], limit=1 + ) + self.assertEqual(membership_line.membership_id, self.product_membership_skip) + + def test_generate_subscription_date_range_with_skip_invoice(self): + """Test _generate_subscription_date_range returns correct dates for skip invoice products""" + start_date = date.today() + subscription = self.env["sale.subscription"].create( + { + "partner_id": self.partner.id, + "template_id": self.subscription_template.id, + "date_start": start_date, + "recurring_next_date": start_date, + "pricelist_id": self.pricelist.id, + } + ) + + self.env["sale.subscription.line"].create( + { + "sale_subscription_id": subscription.id, + "product_id": self.product_membership_skip.id, + "name": "Skip Invoice Membership", + "price_unit": 100.0, + "product_uom_qty": 1.0, + } + ) + + date_from, date_to = subscription._generate_subscription_date_range() + + self.assertEqual(date_from, start_date) + expected_date_to = start_date + relativedelta(months=1) + self.assertEqual(date_to, expected_date_to) + + def test_recurring_next_date_update(self): + """Test that recurring_next_date is properly updated after generating membership lines""" + start_date = date.today() + subscription = self.env["sale.subscription"].create( + { + "partner_id": self.partner.id, + "template_id": self.subscription_template.id, + "date_start": start_date, + "recurring_next_date": start_date, + "pricelist_id": self.pricelist.id, + } + ) + + self.env["sale.subscription.line"].create( + { + "sale_subscription_id": subscription.id, + "product_id": self.product_membership_skip.id, + "name": "Skip Invoice Membership", + "price_unit": 100.0, + "product_uom_qty": 1.0, + } + ) + + initial_next_date = subscription.recurring_next_date + + subscription.generate_invoice() + + self.assertNotEqual(subscription.recurring_next_date, initial_next_date) + expected_next_date = start_date + relativedelta(months=1) + self.assertEqual(subscription.recurring_next_date, expected_next_date) + + def test_multiple_skip_invoice_products(self): + """Test subscription with multiple skip invoice products creates multiple membership lines""" + subscription = self.env["sale.subscription"].create( + { + "partner_id": self.partner.id, + "template_id": self.subscription_template.id, + "date_start": date.today(), + "recurring_next_date": date.today(), + "pricelist_id": self.pricelist.id, + } + ) + + product_skip_2 = self.env["product.product"].create( + { + "name": "Second Skip Invoice Product", + "type": "service", + "list_price": 75.0, + "membership": True, + "membership_skip_invoice": True, + "subscribable": True, + "subscription_template_id": self.subscription_template.id, + } + ) + + self.env["sale.subscription.line"].create( + { + "sale_subscription_id": subscription.id, + "product_id": self.product_membership_skip.id, + "name": "First Skip Invoice", + "price_unit": 0, + "product_uom_qty": 1.0, + } + ) + + self.env["sale.subscription.line"].create( + { + "sale_subscription_id": subscription.id, + "product_id": product_skip_2.id, + "name": "Second Skip Invoice", + "price_unit": 75.0, + "product_uom_qty": 1.0, + } + ) + + initial_membership_count = self.env["membership.membership_line"].search_count( + [("partner", "=", self.partner.id)] + ) + + subscription.generate_invoice() + + final_membership_count = self.env["membership.membership_line"].search_count( + [("partner", "=", self.partner.id)] + ) + + self.assertEqual(final_membership_count, initial_membership_count + 2) + + membership_lines = self.env["membership.membership_line"].search( + [("partner", "=", self.partner.id)] + ) + membership_products = membership_lines.mapped("membership_id") + self.assertIn(self.product_membership_skip, membership_products) + self.assertIn(product_skip_2, membership_products) diff --git a/membership_subscription/views/product_template.xml b/membership_subscription/views/product_template.xml index 4829b782..b470f809 100644 --- a/membership_subscription/views/product_template.xml +++ b/membership_subscription/views/product_template.xml @@ -12,6 +12,7 @@ name="subscription_template_id" attrs="{'invisible': [('subscribable', '=', False)],'required': [('subscribable', '=', True)]}" /> +