Skip to content

Commit

Permalink
Support paying with revolut
Browse files Browse the repository at this point in the history
This gives us apple and google pay. We can later remove direct paypal
payments if unused.
  • Loading branch information
mariocj89 committed Mar 9, 2025
1 parent 1a30c66 commit 875b285
Show file tree
Hide file tree
Showing 18 changed files with 348 additions and 15 deletions.
1 change: 1 addition & 0 deletions .github/workflows/test-and-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ jobs:
make test
env:
EAS_PAYPAL_SECRET: ${{ secrets.EAS_PAYPAL_SECRET }}
EAS_REVOLUT_SECRET: ${{ secrets.EAS_REVOLUT_SECRET }}
EAS_DATALAMA_APIK: ${{ secrets.EAS_DATALAMA_APIK }}

- name: Check pip frozen reqs
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ test:
.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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
18 changes: 18 additions & 0 deletions eas/api/migrations/0021_payment_revolut_id.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
1 change: 1 addition & 0 deletions eas/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions eas/api/paypal.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,12 @@ 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":
LOG.info("Payment[%s] declined", payment_id)
else:
LOG.error("Payment[%s] failed: %r", payment_id, error)
raise Exception("Failed to process PayPal Payment")
return False
52 changes: 52 additions & 0 deletions eas/api/revolut.py
Original file line number Diff line number Diff line change
@@ -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"
11 changes: 11 additions & 0 deletions eas/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
128 changes: 128 additions & 0 deletions eas/api/tests/int/test_revolut.py
Original file line number Diff line number Diff line change
@@ -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"]
6 changes: 6 additions & 0 deletions eas/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<draw_id>[^/]+)/$",
views.revolut_accept,
name="revolut-accept",
),
re_path(
r"secret-santa-admin/(?P<pk>[^/]+)/$",
views.secret_santa_admin,
Expand Down
86 changes: 74 additions & 12 deletions eas/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
Expand All @@ -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")),
Expand All @@ -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)


Expand Down
Loading

0 comments on commit 875b285

Please sign in to comment.