Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Payment handling #15

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions code/backend/billing/apps.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
from logging import getLogger
from importlib import import_module
from django.apps import AppConfig
from django.conf import settings
from django.utils.module_loading import import_string

logger = getLogger(__name__)


class BillingConfig(AppConfig):
name = "billing"

def ready(self):
service_class = import_string(settings.BILLING_SERVICE)
self.service = service_class()
logger.info("Using billing service: %s", self.service)
45 changes: 0 additions & 45 deletions code/backend/billing/services.py

This file was deleted.

Empty file.
18 changes: 18 additions & 0 deletions code/backend/billing/services/interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from abc import ABC, abstractmethod


class BillingService(ABC):
"""Abstract base class for Billing services."""

def __init__(self):
"""Should have no parameters.
It can get it's config values from environment variables, e.g. using os.environ.
"""

@abstractmethod
def send_seat_invoice(self, seat):
...

@abstractmethod
def send_appointment_invoice(self, appointment):
...
13 changes: 13 additions & 0 deletions code/backend/billing/services/noop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from .interface import BillingService


class NoopService(BillingService):
"""
Provides no billing service. Does nothing when sending an invoice.
"""

def send_seat_invoice(self, seat):
pass

def send_appointment_invoice(self, appointment):
pass
54 changes: 54 additions & 0 deletions code/backend/billing/services/rollet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import os
from typing import List
from collections import defaultdict
import logging
from django.conf import settings
from online_payments.billing.enums import Currency
from online_payments.billing.models import Item, PaymentMethod, Invoice, Customer
from online_payments.billing.szamlazzhu import Szamlazzhu
from payments.prices import PRODUCTS, get_product_items
from .interface import BillingService

logger = logging.getLogger(__name__)


class SzamlazzhuService(BillingService):
"""
Uses Rollet proprietary online_payments package,
which uses szamlazz.hu billing provider to send invoices on payments.
"""

def __init__(self):
self._szamlazzhu = Szamlazzhu(os.environ["SZAMLAZZHU_AGENT_KEY"], Currency.HUF)

def send_seat_invoice(self, seat):
self._send_invoice(seat.appointment.billing_detail, seat.appointment.email, self._get_items_for_seats([seat]))

def send_appointment_invoice(self, appointment):
self._send_invoice(
appointment.billing_detail, appointment.email, self._get_items_for_seats(appointment.seats.all())
)

def _get_items_for_seats(self, seats) -> List[Item]:
grouped_products = defaultdict(int)
for seat in seats:
grouped_products[seat.payment.product_type] += 1

items = []
for product_type, quantity in grouped_products.items():
items.extend(get_product_items(PRODUCTS[product_type], quantity))

return items

def _send_invoice(self, billing_detail, email, items):
customer = Customer(
name=billing_detail.company_name,
post_code=billing_detail.post_code,
city=billing_detail.city,
address=billing_detail.address_line1,
email=email,
tax_number=billing_detail.tax_number,
)
invoice = Invoice(items=items, payment_method=PaymentMethod.CREDIT_CARD, customer=customer)
logger.info("Sending invoice to: %s", email)
self._szamlazzhu.send_invoice(invoice, os.environ["SZAMLAZZHU_INVOICE_PREFIX"])
5 changes: 3 additions & 2 deletions code/backend/billing/tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
from decimal import Decimal
from unittest.mock import Mock
import pytest
from django.apps import apps as django_apps
from online_payments.billing.szamlazzhu import Szamlazzhu
from online_payments.billing.models import Item, Invoice, Customer, VATRate, PaymentMethod
from appointments.models import Appointment, Seat
from payments.models import Payment
from payments.prices import ProductType
from billing.models import BillingDetail
from billing.services import send_seat_invoice


@pytest.mark.django_db
Expand All @@ -27,7 +27,8 @@ def test_send_invoice_called(self, monkeypatch, billing_detail):
payment=Payment(amount=amount, product_type=ProductType.NORMAL_EXAM),
appointment=appointment,
)
send_seat_invoice(seat)
billing = django_apps.get_app_config("billing")
billing.service.send_seat_invoice(seat)

item1 = Item(
name="Laboratóriumi teszt - Alapcsomag (72 óra)",
Expand Down
6 changes: 3 additions & 3 deletions code/backend/billing/tests/test_szamlazzhu_invoicing.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import pytest

from django.apps import apps as django_apps
from appointments import models as am
from billing import models as m
from billing import services
from payments import models as pm
from payments.prices import PRODUCTS, ProductType

Expand All @@ -25,4 +24,5 @@ def test_sending_invoice(appointment, seat):
)
appointment.refresh_from_db()
seat.refresh_from_db()
services.send_seat_invoice(seat)
billing = django_apps.get_app_config("billing")
billing.service.send_seat_invoice(seat)
11 changes: 10 additions & 1 deletion code/backend/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from unittest.mock import Mock
from urllib.parse import urljoin
from pathlib import Path
import datetime as dt
from django.apps import apps as django_apps
from django.contrib.auth.models import Group, Permission
from django.utils import timezone
from rest_framework.reverse import reverse
Expand All @@ -21,7 +23,6 @@ def factory():

@pytest.fixture(autouse=True)
def django_settings(settings):
settings.SIMPLEPAY_SECRET_KEY = "simple-secret"
settings.FRONTEND_URL = "http://frontend-url"


Expand Down Expand Up @@ -171,3 +172,11 @@ def appointment_billing_detail(appointment):
tax_number="123456789",
appointment=appointment,
)


@pytest.fixture
def send_seat_invoice_mock(monkeypatch):
mock_send = Mock()
billing = django_apps.get_app_config("billing")
monkeypatch.setattr(billing.service, "send_seat_invoice", mock_send)
return mock_send
6 changes: 0 additions & 6 deletions code/backend/payments/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,4 @@ class InlinePaymentAdmin(admin.TabularInline):
extra = 1


# class SimplePayTransactionAdmin(admin.ModelAdmin):
# inlines = [InlinePaymentAdmin]


admin.site.register(m.Payment, PaymentAdmin)
# admin.site.register(m.SimplePayTransaction, SimplePayTransactionAdmin)
admin.site.register(m.SimplePayTransaction)
37 changes: 1 addition & 36 deletions code/backend/payments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class Payment(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
seat = models.OneToOneField("appointments.Seat", on_delete=models.SET_NULL, null=True)
simplepay_transactions = models.ManyToManyField(
"SimplePayTransaction", blank=True, null=True, related_name="payments"
"simplepay.SimplePayTransaction", blank=True, null=True, related_name="payments"
)
payment_method_type = models.CharField(max_length=255, choices=PAYMENT_METHOD_TYPE_CHOICES)
product_type = models.CharField(max_length=50, choices=PRODUCT_CHOICES)
Expand Down Expand Up @@ -39,38 +39,3 @@ def is_paid(self):
@property
def product(self):
return PRODUCTS[self.product_type]


class SimplePayTransaction(models.Model):
STATUS_CREATED = "CREATED"
STATUS_WAITING_FOR_AUTHORIZATION = "WAITING_FOR_AUTHORIZATION"
STATUS_AUTHORIZED = "AUTHORIZED"
STATUS_WAITING_FOR_COMPLETION = "WAITING_FOR_COMPLETION"
STATUS_COMPLETED = "COMPLETED"
STATUS_WAITING_FOR_REFUND = "WAITING_FOR_REFUND"
STATUS_REFUNDED = "REFUNDED"
STATUS_REJECTED = "REJECTED"
STATUS_CANCELLED = "CANCELLED"
STATUS_CHOICES = [
(STATUS_CREATED, _("created")),
(STATUS_WAITING_FOR_AUTHORIZATION, _("waiting for authorization")),
(STATUS_AUTHORIZED, _("authorized")),
(STATUS_WAITING_FOR_COMPLETION, _("waiting for completion")),
(STATUS_COMPLETED, _("completed")),
(STATUS_WAITING_FOR_REFUND, _("waiting for refund")),
(STATUS_REFUNDED, _("refunded")),
(STATUS_REJECTED, _("rejected")),
(STATUS_CANCELLED, _("cancelled")),
]

uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
amount = models.DecimalField(max_digits=7, decimal_places=2)
currency = models.CharField(max_length=3, default="HUF")
external_reference_id = models.CharField(max_length=255, blank=True, default="")
status = models.CharField(max_length=255, choices=STATUS_CHOICES, default=STATUS_CREATED)
created_at = models.DateTimeField(auto_now_add=True)

class Meta:
ordering = ("created_at",)
verbose_name = "SimplePay transaction"
verbose_name_plural = "SimplePay transactions"
41 changes: 3 additions & 38 deletions code/backend/payments/services.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import datetime as dt

from django.utils.translation import gettext as _
from online_payments.billing.szamlazzhu.exceptions import SzamlazzhuError
from rest_framework.exceptions import ValidationError
from appointments.models import QRCode
from appointments import email

from billing import services as billing_services
from django.apps import apps as django_apps


MISSING = object()
Expand All @@ -30,34 +24,5 @@ def handle_paid_at(original_paid_at, seat, submitted_data: dict):
return

if original_paid_at is None and submitted_data.get("paid_at"):
billing_services.send_seat_invoice(seat)


def complete_transaction(transaction, finish_date):
"""
This happens in the context of simplepay payment for the entire appointment.
"""

transaction.status = transaction.STATUS_COMPLETED
transaction.save()

# any payment and seat are ok to find the right appointment
appointment = transaction.payments.first().seat.appointment
appointment.is_registration_completed = True
appointment.save()

transaction.payments.all().update(paid_at=finish_date)

for seat in appointment.seats.all():
QRCode.objects.create(seat=seat)

# Need to query seats again
for seat in appointment.seats.all():
if not seat.email:
raise ValidationError({"email": "Email field is required"})
email.send_qrcode(seat)

try:
billing_services.send_appointment_invoice(appointment)
except SzamlazzhuError as e:
raise ValidationError({"error": str(e)})
billing = django_apps.get_app_config("billing")
billing.service.send_seat_invoice(seat)
6 changes: 1 addition & 5 deletions code/backend/payments/tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import pytest

from appointments.models import Seat
from billing import services as billing_services
from payments import services

DATE = dt.datetime(2020, 1, 1, 12)
Expand Down Expand Up @@ -43,10 +42,7 @@ def test_validate_paid_at(original_paid_at, submitted_data, raises_error):
(DATE, {"paid_at": OTHER_DATE}, False),
),
)
def test_handle_paid_at(original_paid_at, submitted_data, should_send_invoice, monkeypatch):
send_seat_invoice_mock = Mock()
monkeypatch.setattr(billing_services, "send_seat_invoice", send_seat_invoice_mock)

def test_handle_paid_at(original_paid_at, submitted_data, should_send_invoice, send_seat_invoice_mock):
services.handle_paid_at(original_paid_at, Seat(), submitted_data)

if should_send_invoice:
Expand Down
9 changes: 5 additions & 4 deletions code/backend/payments/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import base64
from unittest.mock import Mock

from django.apps import apps as django_apps
from django.core import mail
from django.utils import timezone
from django.db.models import Sum
Expand All @@ -21,7 +22,6 @@
simplepay_back_view as back_view,
simplepay,
)
from billing import services as billing_services
from ..prices import ProductType, PaymentMethodType
from .. import models as m

Expand Down Expand Up @@ -376,8 +376,9 @@ class TestSimplePayIPNView:

@pytest.mark.django_db
def test_success(self, factory, monkeypatch, transaction, api_client, appointment_billing_detail):
mock_send_appointment_invoice = Mock()
monkeypatch.setattr(billing_services, "send_appointment_invoice", mock_send_appointment_invoice)
send_appointment_invoice_mock = Mock()
billing = django_apps.get_app_config("billing")
monkeypatch.setattr(billing.service, "send_appointment_invoice", send_appointment_invoice_mock)
now = timezone.now()

transaction.external_reference_id = "111"
Expand Down Expand Up @@ -405,7 +406,7 @@ def test_success(self, factory, monkeypatch, transaction, api_client, appointmen
assert appointment_billing_detail.appointment.is_registration_completed
assert QRCode.objects.count() == 1
assert len(mail.outbox) == 1
mock_send_appointment_invoice.assert_called()
send_appointment_invoice_mock.assert_called()

def test_ipn_error_is_handled(self, api_client, monkeypatch):
mock_ipn_process = Mock(side_effect=(IPNError()))
Expand Down
Loading