#2 #3 add membership_payment_date: membership start date -> payment date. Don't overlap membership periods on renewals
This commit is contained in:
parent
54965d483f
commit
d8cd83bdaf
12 changed files with 511 additions and 0 deletions
313
membership_payment_date/tests/test_membership_payment_date.py
Normal file
313
membership_payment_date/tests/test_membership_payment_date.py
Normal 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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue