From 54965d483f551f64ebeaaad7a0ddb0cca1a7399e Mon Sep 17 00:00:00 2001 From: luis Date: Tue, 14 Jul 2026 11:53:52 +0200 Subject: [PATCH 1/2] website_membership_signup_required: phone not required --- .../controllers/website_sale.py | 29 +++++++- .../tests/test_membership_signup.py | 66 ++++++++++++++++++- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/website_membership_signup_required/controllers/website_sale.py b/website_membership_signup_required/controllers/website_sale.py index 6a9a109..16d0194 100644 --- a/website_membership_signup_required/controllers/website_sale.py +++ b/website_membership_signup_required/controllers/website_sale.py @@ -95,4 +95,31 @@ class WebsiteSaleMembershipSignup(WebsiteSale): product_custom_attribute_values=product_custom_attribute_values, no_variant_attribute_value_ids=no_variant_attribute_value_ids, **kwargs - ) \ No newline at end of file + ) + + def _get_mandatory_address_fields(self, country_sudo): + # EMES: the phone field is not mandatory at checkout, for any flow + # (membership or regular). The core's default set in + # `website_sale/controllers/main.py:_get_mandatory_address_fields` + # includes 'phone' alongside name/street/city/country_id. Removing it + # here cascades to both billing and delivery mandatory fields (both + # delegates to this method) and propagates to the frontend: the + # hidden `required_fields` input sent to the address form no longer + # lists 'phone', so `address.js` neither marks the input as + # `:required` nor shows the red asterisk, and the server-side + # `_check_billing_address` / `_check_delivery_address` no longer + # reject empty phones. + fields = super()._get_mandatory_address_fields(country_sudo) + fields.discard("phone") + return fields + + def _get_mandatory_fields(self): + # Portal's `_get_mandatory_fields` (in + # `portal/controllers/portal.py`) returns + # `["name", "phone", "email", "street", "city", "country_id"]`, + # and `website_sale`'s `_get_mandatory_billing_address_fields` + # UNIONS that set with the one from `_get_mandatory_address_fields`. + # So even after we drop 'phone' from the address set above, the + # portal set re-adds it for billing. We need to drop it here too. + fields = list(super()._get_mandatory_fields()) + return [f for f in fields if f != "phone"] \ No newline at end of file diff --git a/website_membership_signup_required/tests/test_membership_signup.py b/website_membership_signup_required/tests/test_membership_signup.py index e956206..bd4e277 100644 --- a/website_membership_signup_required/tests/test_membership_signup.py +++ b/website_membership_signup_required/tests/test_membership_signup.py @@ -7,6 +7,9 @@ from odoo.fields import Date from odoo.addons.website_membership_signup_required.controllers.main import ( AuthSignupHomeMembership as AuthSignupHome, ) +from odoo.addons.website_membership_signup_required.controllers.website_sale import ( + WebsiteSaleMembershipSignup, +) from odoo.addons.website.tools import MockRequest from odoo.tests import TransactionCase, tagged @@ -119,7 +122,14 @@ class TestMembershipSignup(TransactionCase): values = controller._prepare_signup_values(qcontext) self.assertEqual(values.get("firstname"), "John") self.assertEqual(values.get("lastname"), "Doe") - self.assertNotIn("name", values) + # partner_firstname: `name` is composed (lastname + firstname, or + # firstname + lastname depending on _get_names_order) and added by + # our override so that the core signup check (no name or partner) + # passes. partner_firstname's `res.partner.create` will later drop + # this composed `name` and recompute it from the split fields. + self.assertIn("name", values) + self.assertIn("John", values["name"]) + self.assertIn("Doe", values["name"]) # -- RF-08 ------------------------------------------------------------- @@ -135,4 +145,56 @@ class TestMembershipSignup(TransactionCase): self.assertEqual(order.order_line.product_id, self.membership_product) # Confirming the SO drives membership creation (core `membership`). order.action_confirm() - self.assertEqual(order.state, "sale") \ No newline at end of file + self.assertEqual(order.state, "sale") + + # -- Phone not mandatory at checkout (EMES) ---------------------------- + + def test_09_phone_not_mandatory_address_fields(self): + """`phone` must not be part of the mandatory checkout fields, for + any address (billing or delivery) and any flow on the EMES website. + The core default (`website_sale/controllers/main.py: + _get_mandatory_address_fields`) includes 'phone'; our override drops + it. The frontend `address.js` then no longer marks the input + `:required` nor shows the asterisk, and the server-side + `_check_billing_address` / `_check_delivery_address` no longer + reject empty phones. + """ + country = self.env.ref("base.es") + ctrl = WebsiteSaleMembershipSignup() + fields = ctrl._get_mandatory_address_fields(country) + self.assertNotIn("phone", fields) + # Default mandatory fields still present. + self.assertIn("name", fields) + self.assertIn("street", fields) + self.assertIn("city", fields) + self.assertIn("country_id", fields) + + def test_10_phone_not_in_portal_mandatory_fields(self): + """`phone` must also be dropped from the portal-side mandatory + field list (`portal/controllers/portal.py:_get_mandatory_fields` + returns `["name", "phone", "email", "street", "city", + "country_id"]`). `website_sale`'s + `_get_mandatory_billing_address_fields` UNIONS that set with the + address set from `_get_mandatory_address_fields`, so even with the + address override alone, 'phone' would come back via the portal + path for billing. Hence this second override. + """ + ctrl = WebsiteSaleMembershipSignup() + fields = ctrl._get_mandatory_fields() + self.assertNotIn("phone", fields) + # Other portal mandatory fields still present. + self.assertIn("name", fields) + self.assertIn("email", fields) + self.assertIn("country_id", fields) + + def test_11_phone_not_in_mandatory_billing_address_fields(self): + """End-to-end check of the billing set: the union of + `_get_mandatory_address_fields` + `_get_mandatory_fields` (which is + what `_get_mandatory_billing_address_fields` returns) must NOT + contain 'phone'. + """ + country = self.env.ref("base.es") + ctrl = WebsiteSaleMembershipSignup() + billing_fields = ctrl._get_mandatory_billing_address_fields(country) + self.assertNotIn("phone", billing_fields) + self.assertIn("email", billing_fields) \ No newline at end of file From d8cd83bdaf697ae9c6e06dc042bf2d0f8ede653b Mon Sep 17 00:00:00 2001 From: luis Date: Tue, 14 Jul 2026 11:56:57 +0200 Subject: [PATCH 2/2] #2 #3 add membership_payment_date: membership start date -> payment date. Don't overlap membership periods on renewals --- membership_payment_date/__init__.py | 1 + membership_payment_date/__manifest__.py | 19 ++ membership_payment_date/i18n/es.po | 17 + .../i18n/membership_payment_date.pot | 16 + membership_payment_date/models/__init__.py | 1 + .../models/account_move.py | 122 +++++++ .../readme/DESCRIPTION.rst | 6 + membership_payment_date/readme/USAGE.rst | 10 + .../static/description/icon.png | Bin 0 -> 761 bytes .../static/description/icon.svg | 5 + membership_payment_date/tests/__init__.py | 1 + .../tests/test_membership_payment_date.py | 313 ++++++++++++++++++ 12 files changed, 511 insertions(+) create mode 100644 membership_payment_date/__init__.py create mode 100644 membership_payment_date/__manifest__.py create mode 100644 membership_payment_date/i18n/es.po create mode 100644 membership_payment_date/i18n/membership_payment_date.pot create mode 100644 membership_payment_date/models/__init__.py create mode 100644 membership_payment_date/models/account_move.py create mode 100644 membership_payment_date/readme/DESCRIPTION.rst create mode 100644 membership_payment_date/readme/USAGE.rst create mode 100644 membership_payment_date/static/description/icon.png create mode 100644 membership_payment_date/static/description/icon.svg create mode 100644 membership_payment_date/tests/__init__.py create mode 100644 membership_payment_date/tests/test_membership_payment_date.py diff --git a/membership_payment_date/__init__.py b/membership_payment_date/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/membership_payment_date/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/membership_payment_date/__manifest__.py b/membership_payment_date/__manifest__.py new file mode 100644 index 0000000..af8fb57 --- /dev/null +++ b/membership_payment_date/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Membership Payment Date", + "version": "18.0.1.0.0", + "category": "Sales", + "summary": "Use payment date instead of invoice date for membership lines, " + "avoiding overlap on renewals.", + "author": "Criptomart", + "website": "https://git.criptomart.net/criptomart/addons-cm", + "license": "AGPL-3", + "depends": [ + "membership_variable_period", + "account", + ], + "data": [], + "installable": True, + "application": False, +} diff --git a/membership_payment_date/i18n/es.po b/membership_payment_date/i18n/es.po new file mode 100644 index 0000000..b8d960c --- /dev/null +++ b/membership_payment_date/i18n/es.po @@ -0,0 +1,17 @@ +# Spanish translation for Membership Payment Date. +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +# +msgid "" +msgstr "" +"Project-Id-Version: membership_payment_date 18.0.1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-01-01 00:00+0000\n" +"PO-Revision-Date: 2025-01-01 00:00+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" diff --git a/membership_payment_date/i18n/membership_payment_date.pot b/membership_payment_date/i18n/membership_payment_date.pot new file mode 100644 index 0000000..0f8d678 --- /dev/null +++ b/membership_payment_date/i18n/membership_payment_date.pot @@ -0,0 +1,16 @@ +# Translation template for Membership Payment Date. +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +# +msgid "" +msgstr "" +"Project-Id-Version: membership_payment_date 18.0.1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-01-01 00:00+0000\n" +"PO-Revision-Date: 2025-01-01 00:00+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" diff --git a/membership_payment_date/models/__init__.py b/membership_payment_date/models/__init__.py new file mode 100644 index 0000000..9c0a421 --- /dev/null +++ b/membership_payment_date/models/__init__.py @@ -0,0 +1 @@ +from . import account_move diff --git a/membership_payment_date/models/account_move.py b/membership_payment_date/models/account_move.py new file mode 100644 index 0000000..6c833b0 --- /dev/null +++ b/membership_payment_date/models/account_move.py @@ -0,0 +1,122 @@ +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import math +from datetime import timedelta + +from odoo import fields, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + def _invoice_paid_hook(self): + """Override: update membership_lines dates upon invoice payment. + + RF-01: Use the payment date instead of the invoice date for date_from. + RF-02: Renewals: if the member has an active membership, the new one + starts the day after the current one expires, avoiding overlap. + RF-03: For variable products, recalculate date_to from the new date_from. + """ + res = super()._invoice_paid_hook() + for invoice in self.filtered(lambda m: m.move_type == "out_invoice"): + membership_lines = self.env["membership.membership_line"].search( + [ + ("account_invoice_line", "in", invoice.invoice_line_ids.ids), + ("date_cancel", "=", False), + ] + ) + if not membership_lines: + continue + payment_date = self._get_membership_payment_date(invoice) + for mline in membership_lines: + partner = mline.partner + product = mline.membership_id + quantity = ( + mline.account_invoice_line.quantity + if mline.account_invoice_line + else 1.0 + ) + date_from, date_to = self._compute_membership_dates( + partner, + product, + payment_date, + quantity, + exclude_line_ids=membership_lines.ids, + ) + mline.write( + { + "date_from": date_from, + "date_to": date_to, + } + ) + return res + + def _get_membership_payment_date(self, invoice): + """Get the date of the most recent reconciled payment. + + If there are no payments (e.g. zero-amount invoice), fallback to today. + """ + self.ensure_one() + payments = invoice.reconciled_payment_ids + if payments: + return max(payments.mapped("date")) + return fields.Date.today() + + def _compute_membership_dates( + self, partner, product, payment_date, quantity=1.0, + exclude_line_ids=None, + ): + """Calculate date_from and date_to for a membership line. + + :param partner: res.partner (member) + :param product: product.product (membership product) + :param payment_date: effective payment date + :param quantity: product quantity on the invoice line + :param exclude_line_ids: membership line IDs to exclude from the + active membership search (to avoid matching the lines we are + currently updating) + :return: tuple (date_from, date_to) + """ + domain = [ + ("partner", "=", partner.id), + ("date_cancel", "=", False), + ("state", "in", ("paid", "invoiced")), + ("date_to", ">=", fields.Date.today()), + ] + if exclude_line_ids: + domain.append(("id", "not in", exclude_line_ids)) + # RF-02: Look for an active membership (not cancelled, not expired) + active_membership = self.env["membership.membership_line"].search( + domain, + limit=1, + order="date_to desc", + ) + + if active_membership: + # RF-02: Renewal — start the day after the current one expires + date_from = active_membership.date_to + timedelta(days=1) + else: + # RF-01: New membership — use the payment date + date_from = payment_date + + # RF-03: Calculate date_to according to membership type + if product.membership_type == "variable": + qty = int(math.ceil(quantity)) + next_date = product._get_next_date(date_from, qty=qty) + date_to = next_date and (next_date - timedelta(days=1)) or False + else: + date_to = product.membership_date_to + + # Safety: respect the product's lower bound + if product.membership_date_from and date_from < product.membership_date_from: + date_from = product.membership_date_from + + # Safety: ensure date_from does not exceed date_to + if date_to and date_from > date_to: + if active_membership: + date_from = date_to + else: + date_from = min(payment_date, date_to) + + return date_from, date_to diff --git a/membership_payment_date/readme/DESCRIPTION.rst b/membership_payment_date/readme/DESCRIPTION.rst new file mode 100644 index 0000000..6c6c99f --- /dev/null +++ b/membership_payment_date/readme/DESCRIPTION.rst @@ -0,0 +1,6 @@ +This module modifies the membership date calculation to use the effective +payment date instead of the invoice date for membership lines. + +It also prevents overlap on renewals: if a member renews before their current +membership expires, the new membership starts the day after the current one +ends. diff --git a/membership_payment_date/readme/USAGE.rst b/membership_payment_date/readme/USAGE.rst new file mode 100644 index 0000000..5784bd7 --- /dev/null +++ b/membership_payment_date/readme/USAGE.rst @@ -0,0 +1,10 @@ +#. Create a membership product (fixed or variable period). +#. Sell the product to a member via a sales order or invoice. +#. The invoice generates a membership line with provisional dates. +#. When the invoice is paid (e.g. bank transfer registered manually), + the membership line dates are automatically updated: + + * **date_from** is set to the payment date (or the day after the + current active membership expires, in case of renewal). + * **date_to** is recalculated based on the new date_from for + variable-period products. diff --git a/membership_payment_date/static/description/icon.png b/membership_payment_date/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e32d80d4886dd35e2004914a77ab3148cab9410e GIT binary patch literal 761 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLk0$>1D-C9Ar*7pUN+=qFyJ|` z;gQs~K)LpVF9f~WCa4#mZ(b9r-{8uoaF$_-WP=IwgfxarJPxxMJ&YMLM+HX%WWW={ aE%sNo5`EGk6AFPzn!(f6&t;ucLK6Vi@7UM? literal 0 HcmV?d00001 diff --git a/membership_payment_date/static/description/icon.svg b/membership_payment_date/static/description/icon.svg new file mode 100644 index 0000000..c51d274 --- /dev/null +++ b/membership_payment_date/static/description/icon.svg @@ -0,0 +1,5 @@ + + + M + PD + diff --git a/membership_payment_date/tests/__init__.py b/membership_payment_date/tests/__init__.py new file mode 100644 index 0000000..c187ebb --- /dev/null +++ b/membership_payment_date/tests/__init__.py @@ -0,0 +1 @@ +from . import test_membership_payment_date diff --git a/membership_payment_date/tests/test_membership_payment_date.py b/membership_payment_date/tests/test_membership_payment_date.py new file mode 100644 index 0000000..fdca809 --- /dev/null +++ b/membership_payment_date/tests/test_membership_payment_date.py @@ -0,0 +1,313 @@ +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import date + +from freezegun import freeze_time + +from psycopg2 import ProgrammingError + +from odoo import fields +from odoo.tests import Form, common + + +@freeze_time("2026-01-15") +class TestMembershipPaymentDate(common.TransactionCase): + """Test the membership_payment_date module.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Workaround: website_sale adds base_unit_count as a compute+store+required + # field on product.template with NOT NULL constraint but without precompute. + # The ORM doesn't include it in INSERT for computed stored fields with + # inverse setter, so we set a DB default to avoid the NOT NULL violation. + # This column only exists if website_sale is installed, so we make it + # conditional to allow running tests without that dependency. + for table in ("product_template", "product_product"): + try: + cls.env.cr.execute( + "ALTER TABLE %s " + "ALTER COLUMN base_unit_count SET DEFAULT 0" % table + ) + except ProgrammingError: + cls.env.cr.rollback() + # Accounts + cls.account_receivable = cls.env["account.account"].create( + { + "name": "Test Receivable", + "code": "RECV", + "account_type": "asset_receivable", + "reconcile": True, + } + ) + cls.account_income = cls.env["account.account"].create( + { + "name": "Test Income", + "code": "INCM", + "account_type": "income", + } + ) + # Journals + cls.sale_journal = cls.env["account.journal"].create( + { + "name": "Test Sale Journal", + "code": "TSJ", + "type": "sale", + "default_account_id": cls.account_income.id, + } + ) + cls.bank_journal = cls.env["account.journal"].create( + { + "name": "Test Bank Journal", + "code": "TBJ", + "type": "bank", + } + ) + # Partner + cls.partner = cls.env["res.partner"].create( + { + "name": "Test Member", + "property_account_receivable_id": cls.account_receivable.id, + } + ) + # Fixed membership product (full year 2026) + template_fixed = cls.env["product.template"].create( + { + "name": "Fixed Membership 2026", + "type": "service", + "membership": True, + "membership_type": "fixed", + "membership_date_from": date(2026, 1, 1), + "membership_date_to": date(2026, 12, 31), + "list_price": 100.0, + } + ) + cls.product_fixed = template_fixed.product_variant_id + # Fixed membership product for 2027 (renewal scenario) + template_fixed_2027 = cls.env["product.template"].create( + { + "name": "Fixed Membership 2027", + "type": "service", + "membership": True, + "membership_type": "fixed", + "membership_date_from": date(2027, 1, 1), + "membership_date_to": date(2027, 12, 31), + "list_price": 100.0, + } + ) + cls.product_fixed_2027 = template_fixed_2027.product_variant_id + # Variable membership product (1 year) + template_variable = cls.env["product.template"].create( + { + "name": "Variable Membership", + "type": "service", + "membership": True, + "membership_type": "variable", + "membership_interval_qty": 1, + "membership_interval_unit": "years", + "list_price": 200.0, + } + ) + cls.product_variable = template_variable.product_variant_id + + def _create_invoice(self, product, invoice_date, quantity=1.0): + """Create and return a draft out_invoice with the given product.""" + invoice_form = 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 line_form: + line_form.product_id = product + line_form.price_unit = product.list_price + line_form.quantity = quantity + return invoice_form.save() + + def _post_invoice(self, invoice): + """Post the invoice and return the membership line if any.""" + invoice.action_post() + membership_lines = self.env["membership.membership_line"].search( + [ + ("account_invoice_line", "in", invoice.invoice_line_ids.ids), + ] + ) + return membership_lines + + def _register_payment(self, invoice, payment_date): + """Register a payment for the invoice on the given date.""" + payment_register = ( + self.env["account.payment.register"] + .with_context( + active_model="account.move", + active_ids=invoice.ids, + ) + .create( + { + "amount": invoice.amount_total, + "payment_date": payment_date, + "journal_id": self.bank_journal.id, + } + ) + ) + payment_register._create_payments() + + def _create_active_membership(self, product, date_from, date_to, state="paid"): + """Create a membership line for the test partner.""" + return self.env["membership.membership_line"].create( + { + "partner": self.partner.id, + "membership_id": product.id, + "member_price": product.list_price, + "date": date_from, + "date_from": date_from, + "date_to": date_to, + "state": state, + } + ) + + # --- Tests --- + + def test_01_new_membership_fixed_paid(self): + """New member, fixed product, pay after invoice — date_from = payment_date.""" + invoice = self._create_invoice( + self.product_fixed, invoice_date=date(2026, 1, 1) + ) + mlines = self._post_invoice(invoice) + self.assertEqual(len(mlines), 1) + mline = mlines[0] + # Check initial state + self.assertEqual(mline.state, "invoiced") + # Register payment on 2026-03-15 + self._register_payment(invoice, payment_date=date(2026, 3, 15)) + # Re-read to get updated values + mline = self.env["membership.membership_line"].browse(mline.id) + # Verify dates corrected + self.assertEqual(mline.date_from, date(2026, 3, 15)) + self.assertEqual(mline.date_to, date(2026, 12, 31)) + + def test_02_new_membership_variable_paid(self): + """New member, variable product, pay after invoice — correct date_to.""" + invoice = self._create_invoice( + self.product_variable, invoice_date=date(2026, 1, 1) + ) + mlines = self._post_invoice(invoice) + self.assertEqual(len(mlines), 1) + mline = mlines[0] + # Register payment 6 months later + self._register_payment(invoice, payment_date=date(2026, 6, 15)) + # date_from = payment_date, date_to = payment_date + 1 year - 1 day + self.assertEqual(mline.date_from, date(2026, 6, 15)) + self.assertEqual(mline.date_to, date(2027, 6, 14)) + + def test_03_renewal_before_expiry_fixed(self): + """Active member renews early — new membership starts after expiry.""" + # Create active membership until 2026-12-31 + self._create_active_membership( + self.product_fixed, + date_from=date(2026, 1, 1), + date_to=date(2026, 12, 31), + state="paid", + ) + # Buy a new fixed 2027 product and pay on 2026-11-15 + invoice = self._create_invoice( + self.product_fixed_2027, invoice_date=date(2026, 11, 1) + ) + mlines = self._post_invoice(invoice) + self.assertEqual(len(mlines), 1) + mline = mlines[0] + self._register_payment(invoice, payment_date=date(2026, 11, 15)) + # Must start the day after the active membership expires (no overlap) + self.assertEqual(mline.date_from, date(2027, 1, 1)) + self.assertEqual(mline.date_to, date(2027, 12, 31)) + + def test_04_renewal_before_expiry_variable(self): + """Active member renews early with variable product.""" + self._create_active_membership( + self.product_variable, + date_from=date(2026, 1, 1), + date_to=date(2026, 12, 31), + state="paid", + ) + invoice = self._create_invoice( + self.product_variable, invoice_date=date(2026, 11, 1) + ) + mlines = self._post_invoice(invoice) + self.assertEqual(len(mlines), 1) + mline = mlines[0] + self._register_payment(invoice, payment_date=date(2026, 11, 15)) + # Must start the day after expiry + self.assertEqual(mline.date_from, date(2027, 1, 1)) + # 1-year variable from 2027-01-01 -> ends 2027-12-31 + self.assertEqual(mline.date_to, date(2027, 12, 31)) + + def test_05_renewal_expired_no_overlap(self): + """Member with expired membership — uses payment_date (no active).""" + # Expired membership (date_to < today) + self._create_active_membership( + self.product_fixed, + date_from=date(2025, 1, 1), + date_to=date(2025, 12, 31), + state="paid", + ) + invoice = self._create_invoice( + self.product_fixed, invoice_date=date(2026, 3, 1) + ) + mlines = self._post_invoice(invoice) + mline = mlines[0] + self._register_payment(invoice, payment_date=date(2026, 3, 15)) + # No active membership -> date_from = payment_date + self.assertEqual(mline.date_from, date(2026, 3, 15)) + self.assertEqual(mline.date_to, date(2026, 12, 31)) + + def test_06_bank_transfer_flow(self): + """Simulate full bank transfer flow: invoice -> post -> pay.""" + invoice = self._create_invoice( + self.product_fixed, invoice_date=date(2026, 2, 1) + ) + mlines = self._post_invoice(invoice) + self.assertEqual(len(mlines), 1) + mline = mlines[0] + # After posting, state must be "invoiced" + self.assertEqual(mline.state, "invoiced") + # Register payment + self._register_payment(invoice, payment_date=date(2026, 2, 20)) + # State should become "paid" automatically + self.assertEqual(mline.state, "paid") + # Dates must be corrected + self.assertEqual(mline.date_from, date(2026, 2, 20)) + self.assertEqual(mline.date_to, date(2026, 12, 31)) + + def test_07_renewal_same_product(self): + """Renew the same product type — no overlap with existing active membership.""" + # Active membership on product_fixed covers 2026 + self._create_active_membership( + self.product_fixed, + date_from=date(2026, 1, 1), + date_to=date(2026, 12, 31), + state="paid", + ) + # Same conceptual product but for next year + invoice = self._create_invoice( + self.product_fixed_2027, invoice_date=date(2026, 6, 1) + ) + mlines = self._post_invoice(invoice) + self.assertEqual(len(mlines), 1) + mline = mlines[0] + self._register_payment(invoice, payment_date=date(2026, 6, 15)) + # New line starts after the active one expires, no overlap + self.assertEqual(mline.date_from, date(2027, 1, 1)) + self.assertEqual(mline.date_to, date(2027, 12, 31)) + + def test_08_no_active_membership_fallback(self): + """Partner with no memberships at all — date_from = payment_date.""" + invoice = self._create_invoice( + self.product_fixed, invoice_date=date(2026, 5, 1) + ) + mlines = self._post_invoice(invoice) + mline = mlines[0] + self._register_payment(invoice, payment_date=date(2026, 5, 10)) + self.assertEqual(mline.date_from, date(2026, 5, 10)) + self.assertEqual(mline.date_to, date(2026, 12, 31))