diff --git a/membership_payment_date/__init__.py b/membership_payment_date/__init__.py deleted file mode 100644 index 0650744..0000000 --- a/membership_payment_date/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import models diff --git a/membership_payment_date/__manifest__.py b/membership_payment_date/__manifest__.py deleted file mode 100644 index af8fb57..0000000 --- a/membership_payment_date/__manifest__.py +++ /dev/null @@ -1,19 +0,0 @@ -# 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 deleted file mode 100644 index b8d960c..0000000 --- a/membership_payment_date/i18n/es.po +++ /dev/null @@ -1,17 +0,0 @@ -# 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 deleted file mode 100644 index 0f8d678..0000000 --- a/membership_payment_date/i18n/membership_payment_date.pot +++ /dev/null @@ -1,16 +0,0 @@ -# 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 deleted file mode 100644 index 9c0a421..0000000 --- a/membership_payment_date/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import account_move diff --git a/membership_payment_date/models/account_move.py b/membership_payment_date/models/account_move.py deleted file mode 100644 index 6c833b0..0000000 --- a/membership_payment_date/models/account_move.py +++ /dev/null @@ -1,122 +0,0 @@ -# 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 deleted file mode 100644 index 6c6c99f..0000000 --- a/membership_payment_date/readme/DESCRIPTION.rst +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 5784bd7..0000000 --- a/membership_payment_date/readme/USAGE.rst +++ /dev/null @@ -1,10 +0,0 @@ -#. 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 deleted file mode 100644 index e32d80d..0000000 Binary files a/membership_payment_date/static/description/icon.png and /dev/null differ diff --git a/membership_payment_date/static/description/icon.svg b/membership_payment_date/static/description/icon.svg deleted file mode 100644 index c51d274..0000000 --- a/membership_payment_date/static/description/icon.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - M - PD - diff --git a/membership_payment_date/tests/__init__.py b/membership_payment_date/tests/__init__.py deleted file mode 100644 index c187ebb..0000000 --- a/membership_payment_date/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index fdca809..0000000 --- a/membership_payment_date/tests/test_membership_payment_date.py +++ /dev/null @@ -1,313 +0,0 @@ -# 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)) diff --git a/website_membership_signup_required/controllers/website_sale.py b/website_membership_signup_required/controllers/website_sale.py index 16d0194..6a9a109 100644 --- a/website_membership_signup_required/controllers/website_sale.py +++ b/website_membership_signup_required/controllers/website_sale.py @@ -95,31 +95,4 @@ class WebsiteSaleMembershipSignup(WebsiteSale): product_custom_attribute_values=product_custom_attribute_values, no_variant_attribute_value_ids=no_variant_attribute_value_ids, **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"] \ No newline at end of file + ) \ 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 bd4e277..e956206 100644 --- a/website_membership_signup_required/tests/test_membership_signup.py +++ b/website_membership_signup_required/tests/test_membership_signup.py @@ -7,9 +7,6 @@ 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 @@ -122,14 +119,7 @@ class TestMembershipSignup(TransactionCase): values = controller._prepare_signup_values(qcontext) self.assertEqual(values.get("firstname"), "John") self.assertEqual(values.get("lastname"), "Doe") - # 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"]) + self.assertNotIn("name", values) # -- RF-08 ------------------------------------------------------------- @@ -145,56 +135,4 @@ 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") - - # -- 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 + self.assertEqual(order.state, "sale") \ No newline at end of file