diff --git a/cooperator/models/operation_request.py b/cooperator/models/operation_request.py index 64569aa87..cc6671ada 100644 --- a/cooperator/models/operation_request.py +++ b/cooperator/models/operation_request.py @@ -228,6 +228,26 @@ def validate(self): _("The cooperator can't hand over more shares that he/she owns.") ) + if self.share_product_id.product_tmpl_id.force_min_qty: + resulting_share_qty = ( + total_share_dic[self.share_product_id.id] - self.quantity + ) + minimal_share_qty = ( + self.share_product_id.product_tmpl_id.minimum_quantity + ) + if resulting_share_qty != 0 and resulting_share_qty < minimal_share_qty: + raise ValidationError( + _( + "This share type only accept at least {minimal_share_qty} shares. " + "This cooperator can't sell {quantity} share or he will only " + "have {resulting_share_qty} left." + ).format( + minimal_share_qty=minimal_share_qty, + quantity=self.quantity, + resulting_share_qty=resulting_share_qty, + ) + ) + if self.operation_type == "convert": if self.company_id.unmix_share_type: if self.share_product_id.code == self.share_to_product_id.code: diff --git a/cooperator/models/subscription_request.py b/cooperator/models/subscription_request.py index 04a4fdfba..85ad7bf53 100644 --- a/cooperator/models/subscription_request.py +++ b/cooperator/models/subscription_request.py @@ -853,6 +853,17 @@ def validate_subscription_request(self): if self.ordered_parts <= 0: raise UserError(_("Number of share must be greater than 0.")) + if ( + self.share_product_id.product_tmpl_id.force_min_qty + and self.ordered_parts + < self.share_product_id.product_tmpl_id.minimum_quantity + ): + raise UserError( + _("Number of shares must be at least {min_qty}").format( + min_qty=self.share_product_id.product_tmpl_id.minimum_quantity + ) + ) + partner = self.setup_partner() invoice = self.create_invoice(partner) @@ -896,3 +907,19 @@ def put_on_waiting_list(self): ) self._send_waiting_list_mail() self.write({"state": "waiting"}) + + @api.constrains("ordered_parts") + def _check_ordered_parts_quantity(self): + for sub_req in self: + min_qty = sub_req.share_product_id.product_tmpl_id.minimum_quantity + if sub_req.ordered_parts < min_qty: + raise ValidationError( + _( + "The number of shares must be more than the defined minimum " + "quantity of this share. For {share_name}, the minimum is " + "{minimum_quantity}." + ).format( + share_name=sub_req.share_product_id.short_name, + minimum_quantity=min_qty, + ) + ) diff --git a/cooperator/tests/cooperator_test_mixin.py b/cooperator/tests/cooperator_test_mixin.py index 9b81b77b3..12b852fd0 100644 --- a/cooperator/tests/cooperator_test_mixin.py +++ b/cooperator/tests/cooperator_test_mixin.py @@ -36,6 +36,8 @@ def set_up_cooperator_test_data(cls): "categ_id": company_share_category.id, "is_share": True, "default_share_product": True, + "force_min_qty": True, + "minimum_quantity": 2, "by_individual": True, "by_company": True, "list_price": 25, @@ -143,7 +145,7 @@ def create_payment_account_move(cls, invoice, date, amount=None): def get_dummy_subscription_requests_vals(cls, **custom_vals): vals = { "share_product_id": cls.share_y.id, - "ordered_parts": 2, + "ordered_parts": 6, "firstname": "first name", "lastname": "last name", "email": "email@example.net", diff --git a/cooperator/tests/test_cooperator.py b/cooperator/tests/test_cooperator.py index 949a5f7f9..ec32b5fe2 100644 --- a/cooperator/tests/test_cooperator.py +++ b/cooperator/tests/test_cooperator.py @@ -849,8 +849,10 @@ def test_existing_partner_company_dependent_fields_with_membership(self): self.assertFalse(partner.coop_candidate) self.assertFalse(partner.old_member) self.assertNotEqual(partner.cooperator_register_number, 0) - self.assertEqual(partner.number_of_share, 2) - self.assertEqual(partner.total_value, 50) + self.assertEqual(partner.number_of_share, vals["ordered_parts"]) + self.assertEqual( + partner.total_value, vals["ordered_parts"] * self.share_y.list_price + ) self.assertEqual(partner.cooperator_type, "share_y") self.assertEqual(partner.effective_date, date(2023, 6, 21)) # fixme: these should probably be true. see comment in @@ -918,8 +920,10 @@ def test_partner_company_dependent_fields_with_membership(self): self.assertFalse(partner.coop_candidate) self.assertFalse(partner.old_member) self.assertNotEqual(partner.cooperator_register_number, 0) - self.assertEqual(partner.number_of_share, 2) - self.assertEqual(partner.total_value, 50) + self.assertEqual(partner.number_of_share, vals["ordered_parts"]) + self.assertEqual( + partner.total_value, vals["ordered_parts"] * self.share_y.list_price + ) self.assertEqual(partner.cooperator_type, "share_y") self.assertEqual(partner.effective_date, date(2023, 6, 21)) self.assertTrue(partner.data_policy_approved) @@ -1196,12 +1200,13 @@ def test_transfer_operation(self): "source": "operation", } ) + transfer_qty = 1 operation_request = self.env["operation.request"].create( { "operation_type": "transfer", "partner_id": cooperator.id, "share_product_id": self.share_y.id, - "quantity": 1, + "quantity": transfer_qty, "receiver_not_member": True, "subscription_request": [ fields.Command.create(subscription_request_vals) @@ -1213,7 +1218,10 @@ def test_transfer_operation(self): operation_request.approve_operation() last_register_id = self._get_last_register_id() operation_request.execute_operation() - self.assertEqual(cooperator.number_of_share, 1) + self.assertEqual( + cooperator.number_of_share, + subscription_request_vals["ordered_parts"] - transfer_qty, + ) new_cooperator = self.env["res.partner"].search( [("email", "=", "email2@example.net")] ) @@ -1254,18 +1262,57 @@ def test_transfer_operation_form(self): ) for field in ["address", "zip_code", "city", "lang", "iban"]: setattr(sr, field, subscription_request_vals[field]) + transfer_quantity = 4 f.partner_id = cooperator f.share_product_id = self.share_y - f.quantity = 1 + f.quantity = transfer_quantity operation_request = f.save() self.assertEqual(operation_request.subscription_request.state, "transfer") self.assertEqual( operation_request.subscription_request.share_product_id, self.share_y ) - self.assertEqual(operation_request.subscription_request.ordered_parts, 1) + self.assertEqual( + operation_request.subscription_request.ordered_parts, transfer_quantity + ) operation_request.submit_operation() operation_request.approve_operation() + @freeze_time("2023-06-21") + def test_transfer_operation_too_few_shares_left(self): + """ + Test that the subscription request created during a share transfer + operation fails if the number of shares left is smaller than the min. + """ + cooperator = self.create_dummy_cooperator() + f = Form(self.env["operation.request"]) + f.operation_type = "transfer" + f.receiver_not_member = True + with f.subscription_request.new() as sr: + sr.firstname = "first name 2" + sr.lastname = "last name 2" + sr.email = "email2@example.net" + subscription_request_vals = self.get_dummy_subscription_requests_vals() + sr.country_id = self.env["res.country"].browse( + subscription_request_vals["country_id"] + ) + for field in ["address", "zip_code", "city", "lang", "iban"]: + setattr(sr, field, subscription_request_vals[field]) + transfer_quantity = 5 + f.partner_id = cooperator + f.share_product_id = self.share_y + f.quantity = transfer_quantity + operation_request = f.save() + self.assertEqual(operation_request.subscription_request.state, "transfer") + self.assertEqual( + operation_request.subscription_request.share_product_id, self.share_y + ) + self.assertEqual( + operation_request.subscription_request.ordered_parts, transfer_quantity + ) + with self.assertRaises(ValidationError): + operation_request.submit_operation() + operation_request.approve_operation() + @freeze_time("2023-06-21") def test_transfer_operation_existing_cooperator(self): """ @@ -1288,25 +1335,32 @@ def test_transfer_operation_existing_cooperator(self): new_cooperator = self.env["res.partner"].search( [("email", "=", "email2@example.net")] ) + transfered_quantity = 1 operation_request = self.env["operation.request"].create( { "operation_type": "transfer", "partner_id": cooperator.id, "partner_id_to": new_cooperator.id, "share_product_id": self.share_y.id, - "quantity": 1, + "quantity": transfered_quantity, } ) operation_request.submit_operation() operation_request.approve_operation() last_register_id = self._get_last_register_id() operation_request.execute_operation() - self.assertEqual(cooperator.number_of_share, 1) - self.assertEqual(new_cooperator.number_of_share, 3) + self.assertEqual( + cooperator.number_of_share, + subscription_request_vals["ordered_parts"] - transfered_quantity, + ) + self.assertEqual( + new_cooperator.number_of_share, + subscription_request_vals["ordered_parts"] + transfered_quantity, + ) register_entry = self._get_new_register_records(last_register_id) self.assertEqual(register_entry.partner_id, cooperator) self.assertEqual(register_entry.partner_id_to, new_cooperator) - self.assertEqual(register_entry.quantity, 1) + self.assertEqual(register_entry.quantity, transfered_quantity) self.assertEqual(register_entry.share_product_id, self.share_y) self.assertEqual(register_entry.type, "transfer") self.assertEqual(register_entry.share_unit_price, self.share_y.list_price) @@ -1333,7 +1387,7 @@ def test_sell_back_operation(self): operation_request.approve_operation() last_register_id = self._get_last_register_id() operation_request.execute_operation() - self.assertEqual(cooperator.number_of_share, 1) + self.assertEqual(cooperator.number_of_share, 5) register_entry = self._get_new_register_records(last_register_id) self.assertEqual(register_entry.partner_id, cooperator) self.assertFalse(register_entry.partner_id_to) @@ -1358,7 +1412,7 @@ def test_sell_back_all_shares(self): "operation_type": "sell_back", "partner_id": cooperator.id, "share_product_id": self.share_y.id, - "quantity": 2, + "quantity": 6, } ) operation_request.submit_operation() @@ -1373,33 +1427,36 @@ def test_convert_operation(self): Test that the share conversion operation works correctly. """ cooperator = self.create_dummy_cooperator() + share_qty = self.get_dummy_subscription_requests_vals()["ordered_parts"] operation_request = self.env["operation.request"].create( { "operation_type": "convert", "partner_id": cooperator.id, "share_product_id": self.share_y.id, "share_to_product_id": self.share_x.id, - "quantity": 2, + "quantity": share_qty, } ) operation_request.submit_operation() operation_request.approve_operation() last_register_id = self._get_last_register_id() operation_request.execute_operation() - # share_x costs twice as much as share_y, so there is only one share - self.assertEqual(cooperator.number_of_share, 1) + resulting_shares = share_qty * ( + self.share_y.list_price / self.share_x.list_price + ) + self.assertEqual(cooperator.number_of_share, resulting_shares) self.assertEqual(cooperator.cooperator_type, "share_x") share_line = cooperator.share_ids self.assertEqual(share_line.share_product_id, self.share_x) - self.assertEqual(share_line.share_number, 1) + self.assertEqual(share_line.share_number, resulting_shares) self.assertEqual(share_line.share_unit_price, self.share_x.list_price) register_entry = self._get_new_register_records(last_register_id) self.assertEqual(register_entry.partner_id, cooperator) self.assertFalse(register_entry.partner_id_to) - self.assertEqual(register_entry.quantity, 2) + self.assertEqual(register_entry.quantity, share_qty) self.assertEqual(register_entry.share_product_id, self.share_y) self.assertEqual(register_entry.share_to_product_id, self.share_x) - self.assertEqual(register_entry.quantity_to, 1) + self.assertEqual(register_entry.quantity_to, resulting_shares) self.assertEqual(register_entry.type, "convert") self.assertEqual(register_entry.share_unit_price, self.share_y.list_price) self.assertEqual(register_entry.date, date(2023, 6, 21)) @@ -1616,3 +1673,9 @@ def test_create_user_multiple_users(self): self.assertEqual(inactive_user.company_ids, self.env.company) self.assertEqual(inactive_user.company_id, self.env.company) self.assertTrue(inactive_user.active) + + def test_create_subscription_with_too_few_shares(self): + dummy_subscription_vals = self.get_dummy_subscription_requests_vals() + dummy_subscription_vals["ordered_parts"] = 1 + with self.assertRaises(UserError): + self.env["subscription.request"].create(dummy_subscription_vals) diff --git a/cooperator/tests/test_mail_templates.py b/cooperator/tests/test_mail_templates.py index d3aea7c14..3b180dcb2 100644 --- a/cooperator/tests/test_mail_templates.py +++ b/cooperator/tests/test_mail_templates.py @@ -303,14 +303,13 @@ def _test_mail_template_share_transfer_all_shares( } ) last_mail_id = self._get_last_mail_id() + share_qty = self.get_dummy_subscription_requests_vals()["ordered_parts"] operation_request = self.env["operation.request"].create( { "operation_type": "transfer", "partner_id": cooperator.id, "share_product_id": self.share_y.id, - # 2 is all the quality that the partner has. - "quantity": 2, - # TODO: this field should be computed or shouldn't exist. + "quantity": share_qty, "receiver_not_member": True, "subscription_request": [ fields.Command.create(subscription_request_vals) diff --git a/cooperator_website/controllers/main.py b/cooperator_website/controllers/main.py index 96539bc5d..ba40deae3 100644 --- a/cooperator_website/controllers/main.py +++ b/cooperator_website/controllers/main.py @@ -263,6 +263,14 @@ def _additional_validate(self, kwargs, logged, values, post_file): """ return True + def validate_minimum_quantity(self, kwargs): + share_product_id = int(kwargs.get("share_product_id")) + share_product = self.get_share_product(share_product_id)[share_product_id] + qty_share = int(kwargs.get("ordered_parts")) + return ( + not share_product["force_min_qty"] or qty_share >= share_product["min_qty"] + ) + def validation( # noqa: C901 (method too complex) self, kwargs, logged, values, post_file ): @@ -341,6 +349,14 @@ def validation( # noqa: C901 (method too complex) values["error"] = {"iban"} return request.render(redirect, values) + # check subscription respect min qty of shares + if not self.validate_minimum_quantity(kwargs): + values = self.fill_values(values, is_company, logged) + values["error_msg"] = _( + "Number of shares must be greater than {min_qty}" + ).format(min_qty=self.get_share_product_min_quantity(kwargs)) + return request.render(redirect, values) + # check the subscription's amount max_amount = company.subscription_maximum_amount if logged: @@ -394,6 +410,11 @@ def get_share_product(self, share_product_id, **kw): } } + def get_share_product_min_quantity(self, kwargs): + share_product_id = int(kwargs.get("share_product_id")) + share_product = self.get_share_product(share_product_id)[share_product_id] + return share_product["min_qty"] + @http.route( # noqa: C901 (method too complex) ["/subscription/subscribe_share"], type="http", diff --git a/cooperator_website/static/src/js/cooperator.js b/cooperator_website/static/src/js/cooperator.js index c62e91d39..8b5262425 100644 --- a/cooperator_website/static/src/js/cooperator.js +++ b/cooperator_website/static/src/js/cooperator.js @@ -8,37 +8,32 @@ odoo.define("cooperator.oe_cooperator", function (require) { $(document).ready(function () { var ajax = require("web.ajax"); - $(".oe_cooperator").each(function () { - var oe_cooperator = this; - - $("#share_product_id").change(function () { - var share_product_id = $("#share_product_id").val(); - ajax.jsonRpc("/subscription/get_share_product", "call", { - share_product_id: share_product_id, - }).then(function (data) { - $("#share_price").text(data[share_product_id].list_price); - $("#ordered_parts").val(data[share_product_id].min_qty); - if (data[share_product_id].force_min_qty === true) { - $("#ordered_parts").data("min", data[share_product_id].min_qty); - } - $("#ordered_parts").change(); - var $share_price = $("#share_price").text(); - $('input[name="total_parts"]').val( - $("#ordered_parts").val() * $share_price - ); - $('input[name="total_parts"]').change(); - }); - }); - - $(oe_cooperator).on("change", "#ordered_parts", function (event) { + var ensure_share_consistency = function () { + var share_product_id = $("#share_product_id").val(); + ajax.jsonRpc("/subscription/get_share_product", "call", { + share_product_id: share_product_id, + }).then(function (data) { + $("#share_price").text(data[share_product_id].list_price); + const min_qty = data[share_product_id].min_qty; + if (data[share_product_id].force_min_qty === true) { + $("#ordered_parts").attr("min", min_qty); + } var $share_price = $("#share_price").text(); - var $link = $(event.currentTarget); - var quantity = $link[0].value; - var total_part = quantity * $share_price; - $("#total_parts").val(total_part); - return false; + $('input[name="total_parts"]').val( + $("#ordered_parts").val() * $share_price + ); }); + }; + + $(".oe_cooperator").each(function () { + var oe_cooperator = this; + $(oe_cooperator).on( + "change", + "#share_product_id", + ensure_share_consistency + ); + $(oe_cooperator).on("change", "#ordered_parts", ensure_share_consistency); $(oe_cooperator).on("focusout", "input.js_quantity", function () { $("a.js_add_cart_json").trigger("click"); }); diff --git a/cooperator_website/views/subscription_template.xml b/cooperator_website/views/subscription_template.xml index e914e4ecc..ebff671c9 100644 --- a/cooperator_website/views/subscription_template.xml +++ b/cooperator_website/views/subscription_template.xml @@ -327,7 +327,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later id="ordered_parts" step="1" min="1" - value="1" + t-att-value="products[0].minimum_quantity if products[0].minimum_quantity else 1" class="form-control form-control-sm" /> diff --git a/l10n_be_cooperator/tests/test_tax_shelter.py b/l10n_be_cooperator/tests/test_tax_shelter.py index 03ebe4021..9532f2257 100644 --- a/l10n_be_cooperator/tests/test_tax_shelter.py +++ b/l10n_be_cooperator/tests/test_tax_shelter.py @@ -49,7 +49,7 @@ def test_tax_shelter_certificates(self): certificate = certificates[0] self.assertEqual(certificate.partner_id, cooperator) self.assertEqual(certificate.state, "validated") - self.assertEqual(certificate.total_amount, 50) + self.assertEqual(certificate.total_amount, 150) def test_tax_shelter_certificates_not_eligible(self): cooperator = self._create_dummy_cooperator_2021()