diff --git a/Makefile b/Makefile index 867103a..ee8a9c4 100644 --- a/Makefile +++ b/Makefile @@ -9,17 +9,17 @@ install-deps: .PHONY: test test: - $(PYTEST) eas --cov=eas --cov-report=term-missing --cov-fail-under=100 -vv + $(PYTEST) eas --cov=eas --cov-report=term-missing --cov-fail-under=100 --cov-exclude=**/__main__.py -vv .PHONY: lint lint: DJANGO_SETTINGS_MODULE=eas.settings.local $(PYTHON) -m pylint eas - $(PYTHON) -m isort eas --check --recursive + $(PYTHON) -m isort eas --check $(PYTHON) -m black eas --check .PHONY: format format: - $(PYTHON) -m isort eas --recursive + $(PYTHON) -m isort eas $(PYTHON) -m black eas .PHONY: runlocal diff --git a/README.md b/README.md index 68b1ce8..14d9e7d 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The following env vars enable additional services: - `EAS_LAMADAVA_APIK`: Instagram (Get from hikerapi console). - `EAS_LAMATOK_APIK`: TikTok (Get from hikerapi console). - `EAS_PAYPAL_SECRET`: Sandbox KEY for paypal payments. +- `EAS_REVOLUT_SECRET`: Sandbox KEY for revolut payments. All keys are in lastpass. diff --git a/eas/api/instagram/__main__.py b/eas/api/instagram/__main__.py new file mode 100644 index 0000000..bbd94aa --- /dev/null +++ b/eas/api/instagram/__main__.py @@ -0,0 +1,7 @@ +import sys + +from . import get_comments + +if __name__ == "__main__": + for comment in get_comments(sys.argv[1]): + print(comment) diff --git a/eas/api/migrations/0021_payment_revolut_id.py b/eas/api/migrations/0021_payment_revolut_id.py new file mode 100644 index 0000000..e582ad1 --- /dev/null +++ b/eas/api/migrations/0021_payment_revolut_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2025-03-09 18:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0020_payment_secret_santa_alter_payment_draw"), + ] + + operations = [ + migrations.AddField( + model_name="payment", + name="revolut_id", + field=models.CharField(db_index=True, max_length=500, null=True), + ), + ] diff --git a/eas/api/models.py b/eas/api/models.py index 30598cc..b1dd64d 100644 --- a/eas/api/models.py +++ b/eas/api/models.py @@ -338,6 +338,7 @@ class Options(enum.Enum): payed = models.BooleanField(default=False) draw_url = models.URLField(null=True) paypal_id = models.CharField(max_length=500, db_index=True, null=True) + revolut_id = models.CharField(max_length=500, db_index=True, null=True) option_certified = models.BooleanField(default=False) option_support = models.BooleanField(default=False) diff --git a/eas/api/paypal.py b/eas/api/paypal.py index 844ca6d..7c75870 100644 --- a/eas/api/paypal.py +++ b/eas/api/paypal.py @@ -70,6 +70,7 @@ def accept_payment(payment_id, payer_id): # pragma: no cover ) if response.status_code == 201: LOG.info("Payment[%s] execute successfully", payment_id) + return True else: error = response.json() if error.get("name") == "INSTRUMENT_DECLINED": @@ -77,3 +78,4 @@ def accept_payment(payment_id, payer_id): # pragma: no cover else: LOG.error("Payment[%s] failed: %r", payment_id, error) raise Exception("Failed to process PayPal Payment") + return False diff --git a/eas/api/revolut.py b/eas/api/revolut.py new file mode 100644 index 0000000..94237be --- /dev/null +++ b/eas/api/revolut.py @@ -0,0 +1,52 @@ +import logging + +import requests +from django.conf import settings + +LOG = logging.getLogger(__name__) + +REVOLUT_API_V = "2024-09-01" +REVOLUT_API_BASE_URL = ( + "https://sandbox-merchant.revolut.com" + if settings.REVOLUT_MODE == "sandbox" + else "https://merchant.revolut.com" +) + +bearer = settings.REVOLUT_SECRET + + +def create_payment(draw_url, accept_url, amount): + url = f"{REVOLUT_API_BASE_URL}/api/orders" + payload = { + "amount": amount * 100, + "currency": "EUR", + "description": f"Payment for {draw_url}", + "redirect_url": accept_url, + } + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "Revolut-Api-Version": REVOLUT_API_V, + "Authorization": f"Bearer {bearer}", + } + response = requests.post(url, headers=headers, json=payload) + response.raise_for_status() + payment = response.json() + redirect_url = payment["checkout_url"] + LOG.info("Created new payment with id %r and url %r", payment["id"], redirect_url) + return payment["id"], redirect_url + + +def accept_payment(payment_id): + url = f"{REVOLUT_API_BASE_URL}/api/orders/{payment_id}" + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "Revolut-Api-Version": REVOLUT_API_V, + "Authorization": f"Bearer {bearer}", + } + response = requests.get(url, headers=headers) + response.raise_for_status() + status = response.json()["state"] + LOG.info("Payment with id %s is in status %s", payment_id, status) + return status == "completed" diff --git a/eas/api/serializers.py b/eas/api/serializers.py index 7015967..31fad88 100644 --- a/eas/api/serializers.py +++ b/eas/api/serializers.py @@ -294,6 +294,17 @@ class PayPalCreateSerialzier(serializers.Serializer): draw_url = serializers.URLField() +class RevolutCreateSerialzier(serializers.Serializer): + options = serializers.ListField( + child=serializers.ChoiceField( + choices=[v.value for v in models.Payment.Options] + ), + min_length=1, + ) + draw_id = serializers.CharField(max_length=100) + draw_url = serializers.URLField() + + class PromoCodeSerializer(serializers.Serializer): code = serializers.CharField(max_length=8) draw_id = serializers.CharField(max_length=100) diff --git a/eas/api/tests/int/test_revolut.py b/eas/api/tests/int/test_revolut.py new file mode 100644 index 0000000..13865a0 --- /dev/null +++ b/eas/api/tests/int/test_revolut.py @@ -0,0 +1,128 @@ +import os +from unittest import mock + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APILiveServerTestCase + +from eas.api import models + +from .. import factories + + +class RevolutTestEnd2End(APILiveServerTestCase): + def setUp(self): + self.client.default_format = "json" + self.draw_id = factories.RaffleFactory().id + self.create_url = reverse("revolut-create") + self.accept_url = reverse("revolut-accept", kwargs={"draw_id": self.draw_id}) + + @pytest.mark.skipif( + "EAS_REVOLUT_SECRET" not in os.environ, reason="EAS_REVOLUT_SECRET unset" + ) + def test_create_payment_end_to_end(self): + response = self.client.post( + self.create_url, + { + "options": ["CERTIFIED"], + "draw_id": self.draw_id, + "draw_url": "https://test.com", + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.content) + assert response.json()["redirect_url"] + response = self.client.get(self.accept_url) + self.assertEqual(response.status_code, status.HTTP_302_FOUND, response.content) + + +class RevolutTestPublicDraw(APILiveServerTestCase): + def setUp(self): + self.client.default_format = "json" + self.draw_id = factories.RaffleFactory().id + self.create_url = reverse("revolut-create") + self.accept_url = reverse("revolut-accept", kwargs={"draw_id": self.draw_id}) + + @property + def draw(self): + return models.Raffle.objects.get(id=self.draw_id) + + @mock.patch("eas.api.revolut.accept_payment") + @mock.patch("eas.api.revolut.create_payment") + def test_full_payment_public_draw(self, create_payment, _): + assert self.draw.payments == [] + url = reverse("raffle-detail", kwargs=dict(pk=self.draw.id)) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.content) + assert response.json()["payments"] == [] + + create_payment.return_value = "revolut-id", "fake-url" + response = self.client.post( + self.create_url, + { + "options": ["CERTIFIED", "ADFREE", "SUPPORT"], + "draw_id": self.draw.id, + "draw_url": "http://test.com", + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.content) + assert response.json()["redirect_url"] == "fake-url" + + response = self.client.get( + self.accept_url, {"token": "revolut-id", "PayerID": "payer-id"} + ) + self.assertEqual(response.status_code, status.HTTP_302_FOUND, response.content) + assert response.url == "http://test.com" + assert self.draw.payments == ["CERTIFIED", "ADFREE", "SUPPORT"] + + url = reverse("raffle-detail", kwargs=dict(pk=self.draw.id)) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.content) + assert response.json()["payments"] == ["CERTIFIED", "ADFREE", "SUPPORT"] + + +class RevolutTestSecretSanta(APILiveServerTestCase): + def setUp(self): + self.client.default_format = "json" + self.draw = models.SecretSanta() + self.draw.save() + for i in range(3): + result = models.SecretSantaResult( + draw=self.draw, source=f"Person {i}", target=f"Person {2-i}" + ) + result.save() + self.create_url = reverse("revolut-create") + self.accept_url = reverse("revolut-accept", kwargs={"draw_id": self.draw.id}) + + @mock.patch("eas.api.revolut.accept_payment") + @mock.patch("eas.api.revolut.create_payment") + def test_full_payment_secret_santa(self, create_payment, _): + assert self.draw.payments == [] + url = reverse("secret-santa-admin", kwargs=dict(pk=self.draw.id)) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.content) + assert response.json()["payments"] == [] + + create_payment.return_value = "revolut-id", "fake-url" + response = self.client.post( + self.create_url, + { + "options": ["CERTIFIED", "ADFREE", "SUPPORT"], + "draw_id": self.draw.id, + "draw_url": "http://test.com", + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.content) + assert response.json()["redirect_url"] == "fake-url" + + response = self.client.get( + self.accept_url, {"token": "revolut-id", "PayerID": "payer-id"} + ) + self.assertEqual(response.status_code, status.HTTP_302_FOUND, response.content) + assert response.url == "http://test.com" + assert self.draw.payments == ["CERTIFIED", "ADFREE", "SUPPORT"] + + url = reverse("secret-santa-admin", kwargs=dict(pk=self.draw.id)) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.content) + assert response.json()["payments"] == ["CERTIFIED", "ADFREE", "SUPPORT"] diff --git a/eas/api/urls.py b/eas/api/urls.py index 46c273e..8873df5 100644 --- a/eas/api/urls.py +++ b/eas/api/urls.py @@ -29,6 +29,12 @@ ), re_path(r"paypal/create/", views.paypal_create, name="paypal-create"), re_path(r"paypal/accept/", views.paypal_accept, name="paypal-accept"), + re_path(r"revolut/create/", views.revolut_create, name="revolut-create"), + re_path( + r"revolut/accept/(?P[^/]+)/$", + views.revolut_accept, + name="revolut-accept", + ), re_path( r"secret-santa-admin/(?P[^/]+)/$", views.secret_santa_admin, diff --git a/eas/api/views.py b/eas/api/views.py index 06cfb3d..f10282a 100644 --- a/eas/api/views.py +++ b/eas/api/views.py @@ -10,7 +10,16 @@ from rest_framework.exceptions import APIException, ValidationError from rest_framework.response import Response -from . import amazonsqs, instagram, models, paypal, secret_santa, serializers, tiktok +from . import ( + amazonsqs, + instagram, + models, + paypal, + revolut, + secret_santa, + serializers, + tiktok, +) LOG = logging.getLogger(__name__) @@ -341,14 +350,10 @@ def secret_santa_resend_email(request, draw_pk, result_pk): return Response({"new_result": new_result.id}) -@api_view(["POST"]) -def paypal_create(request): - payment_options = models.Payment.Options - LOG.info("Initiating paypal payment for request: %s", request.data) - serializer = serializers.PayPalCreateSerialzier(data=request.data) - serializer.is_valid(raise_exception=True) - data = serializer.validated_data - options = data["options"] +payment_options = models.Payment.Options + + +def calculate_payment(options): ammount = 0 if payment_options.CERTIFIED.value in options: ammount += 1 @@ -358,6 +363,17 @@ def paypal_create(request): ammount += 5 assert ammount != 0 ammount -= 0.01 + return ammount + + +@api_view(["POST"]) +def paypal_create(request): + LOG.info("Initiating paypal payment for request: %s", request.data) + serializer = serializers.PayPalCreateSerialzier(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + options = data["options"] + ammount = calculate_payment(options) paypal_id, paypal_url = paypal.create_payment( draw_url=data["draw_url"], accept_url=request.build_absolute_uri(reverse("paypal-accept")), @@ -384,11 +400,57 @@ def paypal_accept(request): payment_id = request.GET["token"] payer_id = request.GET["PayerID"] LOG.info("Accepting payment for id %r and payer %r", payment_id, payer_id) - paypal.accept_payment(payment_id, payer_id) payment = get_object_or_404(models.Payment, paypal_id=payment_id) - payment.payed = True + if paypal.accept_payment(payment_id, payer_id): + payment.payed = True + payment.save() + LOG.info("Payment %r accepted", payment) + return redirect(payment.draw_url) + + +@api_view(["POST"]) +def revolut_create(request): + LOG.info("Initiating revolut payment for request: %s", request.data) + serializer = serializers.RevolutCreateSerialzier(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + options = data["options"] + ammount = calculate_payment(options) + return_url = request.build_absolute_uri( + reverse("revolut-accept", kwargs={"draw_id": data["draw_id"]}) + ) + payment_id, payment_url = revolut.create_payment( + draw_url=data["draw_url"], + accept_url=return_url, + amount=ammount, + ) + payment = models.Payment( + draw_url=data["draw_url"], + revolut_id=payment_id, + option_certified=payment_options.CERTIFIED.value in options, + option_support=payment_options.SUPPORT.value in options, + option_adfree=payment_options.ADFREE.value in options, + ) + if models.SecretSanta.objects.filter(pk=data["draw_id"]).exists(): + payment.secret_santa_id = data["draw_id"] + else: + payment.draw_id = data["draw_id"] payment.save() - LOG.info("Payment %r accepted", payment) + LOG.info("Payment creation succeeded: %s", payment) + return Response({"redirect_url": payment_url}) + + +@api_view(["GET"]) +def revolut_accept(_, draw_id): + try: + payment = get_object_or_404(models.Payment, secret_santa_id=draw_id) + except Http404: + payment = get_object_or_404(models.Payment, draw_id=draw_id) + LOG.info("Accepting payment for id %r", payment.revolut_id) + if revolut.accept_payment(payment.revolut_id): + payment.payed = True + payment.save() + LOG.info("Payment %r accepted", payment) return redirect(payment.draw_url) diff --git a/eas/settings/base.py b/eas/settings/base.py index 2925d86..5c2e967 100644 --- a/eas/settings/base.py +++ b/eas/settings/base.py @@ -210,3 +210,4 @@ AWS_KEY_SECRET = os.environ.get("EAS_AWS_KEY_SECRET") LAMADAVA_APIK = os.environ.get("EAS_LAMADAVA_APIK", "lamadava-apik") LAMATOK_APIK = os.environ.get("EAS_LAMATOK_APIK", "lamatok-apik") +REVOLUT_SECRET = os.environ.get("EAS_REVOLUT_SECRET") diff --git a/eas/settings/dev.py b/eas/settings/dev.py index 326d376..937f9e6 100644 --- a/eas/settings/dev.py +++ b/eas/settings/dev.py @@ -6,6 +6,7 @@ RAVEN_CONFIG["environment"] = "dev" PAYPAL_MODE = "sandbox" +REVOLUT_MODE = "sandbox" PAYPAL_ID = ( "AUoNbxkShLicONf0kssMlkgUo91p2x-62izyrGc0YGpUDvrR2CtW0RjWAN0dX6qR2RTAkeWMIq2R0dYa" ) diff --git a/eas/settings/local.py b/eas/settings/local.py index b31239d..c1bfaf6 100644 --- a/eas/settings/local.py +++ b/eas/settings/local.py @@ -28,6 +28,7 @@ DEFAULT_RENDERER_CLASSES.append("rest_framework.renderers.BrowsableAPIRenderer") PAYPAL_MODE = "sandbox" +REVOLUT_MODE = "sandbox" PAYPAL_ID = ( "AUoNbxkShLicONf0kssMlkgUo91p2x-62izyrGc0YGpUDvrR2CtW0RjWAN0dX6qR2RTAkeWMIq2R0dYa" ) diff --git a/eas/settings/prod.py b/eas/settings/prod.py index 6acbd7c..b2a8832 100644 --- a/eas/settings/prod.py +++ b/eas/settings/prod.py @@ -74,6 +74,7 @@ } PAYPAL_MODE = "live" +REVOLUT_MODE = "live" PAYPAL_ID = ( "AfcsuQa3-9RejIysZLXVsQewL6uMirZjPWORj5fYvV3OrQmEiTECsp0Ol4-R3D2YgU7EPIgEaaGKTC5H" ) diff --git a/pyproject.toml b/pyproject.toml index 1a17557..5c1c861 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,3 @@ [tool.isort] line_length = 88 +profile = "black" diff --git a/requirements/local.txt b/requirements/local.txt index 33ce020..3eee3bf 100644 --- a/requirements/local.txt +++ b/requirements/local.txt @@ -5,7 +5,7 @@ django-cors-headers==3.13.0 django-coverage-plugin==2.0.3 factory-boy==3.2.1 freezegun==1.2.2 -isort<5 +isort pip==23.0.1 pip-tools==6.8.0 pylint-django==2.4.2 diff --git a/swagger.yaml b/swagger.yaml index ca8b934..2398f84 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -709,6 +709,7 @@ paths: description: Certifies a draw with a promo code tags: - paypal + - revolut '/paypal/create/': post: operationId: paypal_create @@ -727,6 +728,24 @@ paths: $ref: '#/components/schemas/PaypalResponse' tags: - paypal + '/revolut/create/': + post: + operationId: revolut_create + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RevolutCreatePayload' + required: true + responses: + '200': + description: Initiates a new Revolut Payment + content: + application/json: + schema: + $ref: '#/components/schemas/RevolutResponse' + tags: + - revolut /instagram/: post: operationId: instagram_create @@ -1704,6 +1723,33 @@ components: properties: redirect_url: type: string + RevolutCreatePayload: + type: object + required: + - draw_url + - draw_id + - options + properties: + options: + type: array + items: + type: string + enum: + - CERTIFIED + - ADFREE + - SUPPORT + minItems: 1 + draw_url: + type: string + draw_id: + type: string + RevolutResponse: + type: object + required: + - redirect_url + properties: + redirect_url: + type: string DrawMetadata: required: