#2 #3 add membership_payment_date: membership start date -> payment date. Don't overlap membership periods on renewals

This commit is contained in:
Luis 2026-07-14 11:56:57 +02:00
parent 54965d483f
commit d8cd83bdaf
12 changed files with 511 additions and 0 deletions

View file

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