# 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))