Compare commits
2 commits
c4d32c6add
...
d8cd83bdaf
| Author | SHA1 | Date | |
|---|---|---|---|
| d8cd83bdaf | |||
| 54965d483f |
14 changed files with 603 additions and 3 deletions
1
membership_payment_date/__init__.py
Normal file
1
membership_payment_date/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from . import models
|
||||||
19
membership_payment_date/__manifest__.py
Normal file
19
membership_payment_date/__manifest__.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
17
membership_payment_date/i18n/es.po
Normal file
17
membership_payment_date/i18n/es.po
Normal file
|
|
@ -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"
|
||||||
16
membership_payment_date/i18n/membership_payment_date.pot
Normal file
16
membership_payment_date/i18n/membership_payment_date.pot
Normal file
|
|
@ -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"
|
||||||
1
membership_payment_date/models/__init__.py
Normal file
1
membership_payment_date/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from . import account_move
|
||||||
122
membership_payment_date/models/account_move.py
Normal file
122
membership_payment_date/models/account_move.py
Normal file
|
|
@ -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
|
||||||
6
membership_payment_date/readme/DESCRIPTION.rst
Normal file
6
membership_payment_date/readme/DESCRIPTION.rst
Normal file
|
|
@ -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.
|
||||||
10
membership_payment_date/readme/USAGE.rst
Normal file
10
membership_payment_date/readme/USAGE.rst
Normal file
|
|
@ -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.
|
||||||
BIN
membership_payment_date/static/description/icon.png
Normal file
BIN
membership_payment_date/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 761 B |
5
membership_payment_date/static/description/icon.svg
Normal file
5
membership_payment_date/static/description/icon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
||||||
|
<rect width="256" height="256" fill="#7C3AED" rx="32"/>
|
||||||
|
<text x="128" y="140" font-family="Arial,sans-serif" font-size="80" fill="#FFFFFF" text-anchor="middle" font-weight="bold">M</text>
|
||||||
|
<text x="128" y="190" font-family="Arial,sans-serif" font-size="40" fill="#FFFFFF" text-anchor="middle" opacity="0.8">PD</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 417 B |
1
membership_payment_date/tests/__init__.py
Normal file
1
membership_payment_date/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from . import test_membership_payment_date
|
||||||
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))
|
||||||
|
|
@ -95,4 +95,31 @@ class WebsiteSaleMembershipSignup(WebsiteSale):
|
||||||
product_custom_attribute_values=product_custom_attribute_values,
|
product_custom_attribute_values=product_custom_attribute_values,
|
||||||
no_variant_attribute_value_ids=no_variant_attribute_value_ids,
|
no_variant_attribute_value_ids=no_variant_attribute_value_ids,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
@ -7,6 +7,9 @@ from odoo.fields import Date
|
||||||
from odoo.addons.website_membership_signup_required.controllers.main import (
|
from odoo.addons.website_membership_signup_required.controllers.main import (
|
||||||
AuthSignupHomeMembership as AuthSignupHome,
|
AuthSignupHomeMembership as AuthSignupHome,
|
||||||
)
|
)
|
||||||
|
from odoo.addons.website_membership_signup_required.controllers.website_sale import (
|
||||||
|
WebsiteSaleMembershipSignup,
|
||||||
|
)
|
||||||
from odoo.addons.website.tools import MockRequest
|
from odoo.addons.website.tools import MockRequest
|
||||||
from odoo.tests import TransactionCase, tagged
|
from odoo.tests import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
@ -119,7 +122,14 @@ class TestMembershipSignup(TransactionCase):
|
||||||
values = controller._prepare_signup_values(qcontext)
|
values = controller._prepare_signup_values(qcontext)
|
||||||
self.assertEqual(values.get("firstname"), "John")
|
self.assertEqual(values.get("firstname"), "John")
|
||||||
self.assertEqual(values.get("lastname"), "Doe")
|
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 -------------------------------------------------------------
|
# -- RF-08 -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -135,4 +145,56 @@ class TestMembershipSignup(TransactionCase):
|
||||||
self.assertEqual(order.order_line.product_id, self.membership_product)
|
self.assertEqual(order.order_line.product_id, self.membership_product)
|
||||||
# Confirming the SO drives membership creation (core `membership`).
|
# Confirming the SO drives membership creation (core `membership`).
|
||||||
order.action_confirm()
|
order.action_confirm()
|
||||||
self.assertEqual(order.state, "sale")
|
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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue