diff --git a/api/logics.py b/api/logics.py index 7049e21ce..f5d347d15 100644 --- a/api/logics.py +++ b/api/logics.py @@ -991,7 +991,7 @@ def is_penalized(user): return False, None @classmethod - def cancel_order(cls, order, user, state=None): + def cancel_order(cls, order, user, cancel_status=None): # Do not change order status if an is in order # any of these status do_not_cancel = [ @@ -1009,10 +1009,17 @@ def cancel_order(cls, order, user, state=None): if order.status in do_not_cancel: return False, {"bad_request": "You cannot cancel this order"} - # 1) When maker cancels before bond + # 1) If the order status is not the one specified by the user, do not cancel the order. + if cancel_status is not None and order.status != cancel_status: + return False, { + "bad_request": f"Order status {order.status} is not {cancel_status}. " + + "The order may have been taken before it was cancelled" + } + + # 2) When maker cancels before bond # The order never shows up on the book and order # status becomes "cancelled" - if order.status == Order.Status.WFB and order.maker == user: + elif order.status == Order.Status.WFB and order.maker == user: cls.cancel_bond(order.maker_bond) order.update_status(Order.Status.UCA) @@ -1021,7 +1028,7 @@ def cancel_order(cls, order, user, state=None): return True, None - # 2.a) When maker cancels after bond + # 3.a) When maker cancels after bond # # The order disapears from book and goes to cancelled. If strict, maker is charged the bond # to prevent DDOS on the LN node and order book. If not strict, maker is returned @@ -1041,7 +1048,7 @@ def cancel_order(cls, order, user, state=None): return True, None - # 2.b) When maker cancels after bond and before taker bond is locked + # 3.b) When maker cancels after bond and before taker bond is locked # # The order dissapears from book and goes to cancelled. # The bond maker bond is returned. @@ -1060,7 +1067,7 @@ def cancel_order(cls, order, user, state=None): return True, None - # 3) When taker cancels before bond + # 4) When taker cancels before bond # The order goes back to the book as public. # LNPayment "order.taker_bond" is deleted() elif order.status == Order.Status.TAK and order.taker == user: @@ -1072,13 +1079,13 @@ def cancel_order(cls, order, user, state=None): return True, None - # 4) When taker or maker cancel after bond (before escrow) + # 5) When taker or maker cancel after bond (before escrow) # # The order goes into cancelled status if maker cancels. # The order goes into the public book if taker cancels. # In both cases there is a small fee. - # 4.a) When maker cancel after bond (before escrow) + # 5.a) When maker cancel after bond (before escrow) # The order into cancelled status if maker cancels. elif ( order.status in [Order.Status.WF2, Order.Status.WFE] and order.maker == user @@ -1101,7 +1108,7 @@ def cancel_order(cls, order, user, state=None): return True, None - # 4.b) When taker cancel after bond (before escrow) + # 5.b) When taker cancel after bond (before escrow) # The order into cancelled status if mtker cancels. elif ( order.status in [Order.Status.WF2, Order.Status.WFE] and order.taker == user @@ -1123,7 +1130,7 @@ def cancel_order(cls, order, user, state=None): return True, None - # 5) When trade collateral has been posted (after escrow) + # 6) When trade collateral has been posted (after escrow) # # Always goes to CCA status. Collaboration is needed. # When a user asks for cancel, 'order.m/t/aker_asked_cancel' goes True. diff --git a/api/serializers.py b/api/serializers.py index 4bd41fd24..405f14ed6 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -628,6 +628,13 @@ class UpdateOrderSerializer(serializers.Serializer): mining_fee_rate = serializers.DecimalField( max_digits=6, decimal_places=3, allow_null=True, required=False, default=None ) + cancel_status = serializers.ChoiceField( + choices=Order.Status.choices, + allow_null=True, + allow_blank=True, + default=None, + help_text="Status the order should have for the cancel action to take place.", + ) class ClaimRewardSerializer(serializers.Serializer): diff --git a/api/views.py b/api/views.py index 78bd275c8..11f2857ce 100644 --- a/api/views.py +++ b/api/views.py @@ -511,6 +511,7 @@ def take_update_confirm_dispute_cancel(self, request, format=None): mining_fee_rate = serializer.data.get("mining_fee_rate") statement = serializer.data.get("statement") rating = serializer.data.get("rating") + cancel_status = serializer.data.get("cancel_status") # 1) If action is take, it is a taker request! if action == "take": @@ -586,7 +587,7 @@ def take_update_confirm_dispute_cancel(self, request, format=None): # 3) If action is cancel elif action == "cancel": - valid, context = Logics.cancel_order(order, request.user) + valid, context = Logics.cancel_order(order, request.user, cancel_status) if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) diff --git a/docs/assets/schemas/api-latest.yaml b/docs/assets/schemas/api-latest.yaml index afdd94366..581a021ed 100644 --- a/docs/assets/schemas/api-latest.yaml +++ b/docs/assets/schemas/api-latest.yaml @@ -1950,6 +1950,9 @@ components: format: decimal pattern: ^-?\d{0,3}(?:\.\d{0,3})?$ nullable: true + cancel_status: + allOf: + - $ref: '#/components/schemas/StatusEnum' required: - action Version: diff --git a/frontend/src/components/TradeBox/index.tsx b/frontend/src/components/TradeBox/index.tsx index 28d790610..e0ac7815d 100644 --- a/frontend/src/components/TradeBox/index.tsx +++ b/frontend/src/components/TradeBox/index.tsx @@ -152,6 +152,7 @@ const TradeBox = ({ baseUrl, onStartAgain }: TradeBoxProps): JSX.Element => { mining_fee_rate?: number; statement?: string; rating?: number; + cancel_status?: number; } const renewOrder = function (): void { @@ -203,6 +204,7 @@ const TradeBox = ({ baseUrl, onStartAgain }: TradeBoxProps): JSX.Element => { mining_fee_rate, statement, rating, + cancel_status }: SubmitActionProps): void { const robot = garage.getSlot()?.getRobot(); const currentOrder = garage.getSlot()?.order; @@ -219,6 +221,7 @@ const TradeBox = ({ baseUrl, onStartAgain }: TradeBoxProps): JSX.Element => { mining_fee_rate, statement, rating, + cancel_status }, { tokenSHA256: robot?.tokenSHA256 }, ) @@ -244,9 +247,13 @@ const TradeBox = ({ baseUrl, onStartAgain }: TradeBoxProps): JSX.Element => { }); }; - const cancel = function (): void { + const cancel = function (no_confirmation: boolean) { + const currentOrder = garage.getSlot()?.order; setLoadingButtons({ ...noLoadingButtons, cancel: true }); - submitAction({ action: 'cancel' }); + submitAction({ + action: 'cancel', + cancel_status: no_confirmation ? currentOrder?.status : undefined + }); }; const openDispute = function (): void { @@ -834,7 +841,7 @@ const TradeBox = ({ baseUrl, onStartAgain }: TradeBoxProps): JSX.Element => { cancel(true)} openCancelDialog={() => { setOpen({ ...closeAll, confirmCancel: true }); }} diff --git a/tests/api_specs.yaml b/tests/api_specs.yaml index 33e662356..b3bdc4136 100644 --- a/tests/api_specs.yaml +++ b/tests/api_specs.yaml @@ -1990,6 +1990,9 @@ components: format: decimal pattern: ^-?\d{0,3}(?:\.\d{0,3})?$ nullable: true + cancel_status: + allOf: + - $ref: '#/components/schemas/StatusEnum' required: - action Version: diff --git a/tests/test_trade_pipeline.py b/tests/test_trade_pipeline.py index 32a39c9e8..d0e5f5906 100644 --- a/tests/test_trade_pipeline.py +++ b/tests/test_trade_pipeline.py @@ -669,6 +669,54 @@ def test_cancel_public_order(self): self.assertEqual( data["bad_request"], "This order has been cancelled by the maker" ) + + def test_cancel_order_cancel_status(self): + """ + Tests the cancellation of a public order using cancel_status. + """ + trade = Trade(self.client) + trade.publish_order() + data = trade.response.json() + + self.assertEqual(trade.response.status_code, 200) + self.assertResponse(trade.response) + + self.assertEqual(data["status_message"], Order.Status(Order.Status.PUB).label) + + # Cancel order if the order status is public + trade.cancel_order(cancel_status=Order.Status.PUB) + + self.assertEqual(trade.response.status_code, 200) + self.assertResponse(trade.response) + + def test_cancel_order_different_cancel_status(self): + """ + Tests the cancellation of a paused order with a different cancel_status. + """ + trade = Trade(self.client) + trade.publish_order() + trade.pause_order() + data = trade.response.json() + + self.assertEqual(trade.response.status_code, 200) + self.assertResponse(trade.response) + + self.assertEqual(data["status_message"], Order.Status(Order.Status.PAU).label) + + # Try to cancel order if it is public + trade.cancel_order(cancel_status=Order.Status.PUB) + data = trade.response.json() + + self.assertEqual(trade.response.status_code, 400) + self.assertResponse(trade.response) + + self.assertEqual( + data["bad_request"], f"Order status {Order.Status.PAU} is not {Order.Status.PUB}. " + + "The order may have been taken before it was cancelled" + ) + + # Cancel order to avoid leaving pending HTLCs after a successful test + trade.cancel_order() def test_collaborative_cancel_order_in_chat(self): """ diff --git a/tests/utils/trade.py b/tests/utils/trade.py index 16b9b89df..5a8498381 100644 --- a/tests/utils/trade.py +++ b/tests/utils/trade.py @@ -112,11 +112,11 @@ def get_order(self, robot_index=1, first_encounter=False): self.response = self.client.get(path + params, **headers) @patch("api.tasks.send_notification.delay", send_notification) - def cancel_order(self, robot_index=1): + def cancel_order(self, robot_index=1, cancel_status=None): path = reverse("order") params = f"?order_id={self.order_id}" headers = self.get_robot_auth(robot_index) - body = {"action": "cancel"} + body = {"action": "cancel", "cancel_status": cancel_status} self.response = self.client.post(path + params, body, **headers) def pause_order(self, robot_index=1):