Add automatic paypal charging, add ticket page mock

This commit is contained in:
David Rodenkirchen
2026-05-21 11:32:08 +02:00
parent 695b5ae741
commit 6c8c0c7a4f
12 changed files with 321 additions and 29 deletions
+81 -14
View File
@@ -7,6 +7,7 @@ import httpx
import qrcode
from elm.types import Transaction, User, PayPalConfiguration
from elm.services import MailingService, ConfigurationService
logger = logging.getLogger(__name__.split(".")[-1])
@@ -16,16 +17,29 @@ class InsufficientFundsError(Exception):
class AccountingService:
def __init__(self, paypal_config: PayPalConfiguration) -> None:
self._paypal_config = paypal_config
PAYPAL_SANDBOX_URL = "https://api-m.sandbox.paypal.com"
PAYPAL_PROD_URL = "https://api-m.paypal.com"
def __init__(self, configuration_service: ConfigurationService, mailing_service: MailingService) -> None:
self._configuration_service = configuration_service
self._paypal_config: PayPalConfiguration = configuration_service.get_paypal_configuration()
self._pending_paypal_orders: dict[str, tuple[str, Decimal]] = {}
self._mailing_service = mailing_service
async def has_user_open_orders(self, user_name: str) -> bool:
for pending_paypal_order in self._pending_paypal_orders.values():
if pending_paypal_order[0] == user_name:
return True
return False
async def get_paypal_access_token(self) -> str:
url = self.PAYPAL_SANDBOX_URL if self._configuration_service.DEV_MODE_ACTIVE else self.PAYPAL_PROD_URL
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api-m.sandbox.paypal.com/v1/oauth2/token",
f"{url}/v1/oauth2/token",
auth=(
self._paypal_config.client_id_sandbox,
self._paypal_config.secret_sandbox,
self._paypal_config.client_id_sandbox if self._configuration_service.DEV_MODE_ACTIVE else self._paypal_config.client_id,
self._paypal_config.secret_sandbox if self._configuration_service.DEV_MODE_ACTIVE else self._paypal_config.secret,
),
headers={
"Content-Type": "application/x-www-form-urlencoded",
@@ -41,10 +55,13 @@ class AccountingService:
return data["access_token"]
async def start_paypal_process(self, user_name: str, amount: Decimal) -> str:
url = self.PAYPAL_SANDBOX_URL if self._configuration_service.DEV_MODE_ACTIVE else self.PAYPAL_PROD_URL
return_domain = "http://localhost:8000" if self._configuration_service.DEV_MODE_ACTIVE else self._configuration_service.BASE_URL
amount = amount.quantize(Decimal(".01"))
async with httpx.AsyncClient() as client:
access_token = await self.get_paypal_access_token()
response = await client.post(
url="https://api-m.sandbox.paypal.com/v2/checkout/orders/",
url=f"{url}/v2/checkout/orders/",
headers={
"Authorization": f"Bearer {access_token}"
},
@@ -58,16 +75,60 @@ class AccountingService:
"value": str(amount)
}
}
]
],
"payment_source": {
"paypal": {
"experience_context": {
"return_url": f"{return_domain}/return-paypal",
"cancel_url": f"{return_domain}/cancel-paypal",
"user_action": "PAY_NOW",
"shipping_preference": "NO_SHIPPING"
}
}
}
}
)
approval_url = next(
link["href"]
for link in response.json()["links"]
if link["rel"] == "approve"
)
return approval_url
try:
payer_action_url = next(
link["href"]
for link in response.json()["links"]
if link["rel"] == "payer-action"
)
except StopIteration:
logger.error("No payer action url found: %s", response.text)
return "#"
self._pending_paypal_orders[response.json()["id"]] = (user_name, amount)
return payer_action_url
async def finalize_paypal_process(self, order_id: str) -> bool:
url = self.PAYPAL_SANDBOX_URL if self._configuration_service.DEV_MODE_ACTIVE else self.PAYPAL_PROD_URL
async with httpx.AsyncClient() as client:
access_token = await self.get_paypal_access_token()
response = await client.get(
url=f"{url}/v2/checkout/orders/{order_id}",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
)
is_approved = response.json()["status"] == "APPROVED"
if is_approved:
response = await client.post(
f"{url}/v2/checkout/orders/{order_id}/capture",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
)
is_completed = response.json()["status"] == "COMPLETED"
if is_completed:
await self.add_balance(self._pending_paypal_orders[order_id][0], self._pending_paypal_orders[order_id][1], "PayPal Aufladung")
self._pending_paypal_orders.pop(order_id)
return True
return False
async def add_balance(self, user_name: str, balance_to_add: Decimal, title: str) -> Decimal:
user = await User.find_one(User.user_name == user_name)
@@ -80,7 +141,13 @@ class AccountingService:
title=title
).save()
logger.debug(f"Added balance of {self.make_euro_string_from_decimal(balance_to_add)} to user '{user_name}'")
return await self.get_balance(user_name)
new_balance = await self.get_balance(user_name)
await self._mailing_service.send_email(
"Dein Guthaben wurde aufgeladen",
self._mailing_service.generate_account_balance_added_mail_body(user, balance_to_add, new_balance),
user.user_mail
)
return new_balance
async def remove_balance(self, user_name: str, balance_to_remove: Decimal, title: str) -> Decimal:
current_balance = await self.get_balance(user_name)
+21 -1
View File
@@ -1,12 +1,13 @@
import sys
from datetime import datetime
from decimal import Decimal
from pathlib import Path
import logging
import tomllib
from from_root import from_root
from elm.types.ConfigurationTypes import MailingServiceConfiguration, LanInfo, ReceiptPrintingConfiguration, DatabaseConfiguration, PayPalConfiguration
from elm.types.ConfigurationTypes import MailingServiceConfiguration, LanInfo, ReceiptPrintingConfiguration, DatabaseConfiguration, PayPalConfiguration, TicketInfo
logger = logging.getLogger(__name__.split(".")[-1])
logger.setLevel(logging.DEBUG)
@@ -46,6 +47,21 @@ class ConfigurationService:
logger.fatal("Error loading DatabaseConfiguration, exiting...")
sys.exit(1)
def get_ticket_info(self) -> tuple[TicketInfo, ...]:
try:
return tuple([TicketInfo(
category=value,
total_tickets=self._config["tickets"][value]["total_tickets"],
price=Decimal(self._config["tickets"][value]["price"]),
description=self._config["tickets"][value]["description"],
additional_info=self._config["tickets"][value]["additional_info"]
) for value in self._config["tickets"]])
except KeyError as e:
logger.debug(e)
logger.fatal("Error loading ticket configuration, exiting...")
sys.exit(1)
def get_database_configuration(self) -> DatabaseConfiguration:
try:
return DatabaseConfiguration(
@@ -117,3 +133,7 @@ class ConfigurationService:
@property
def DEFAULT_PROFILE_PICTURE(self) -> bytes:
return self._DEFAULT_PROFILE_PICTURE
@property
def BASE_URL(self) -> str:
return self._config["misc"]["base_url"]
+2 -2
View File
@@ -4,7 +4,7 @@ from beanie import init_beanie
from pymongo import AsyncMongoClient
from pymongo.asynchronous.collection import AsyncCollection
from elm.types import User, Transaction
from elm.types import User, Transaction, Ticket
from elm.types.ConfigurationTypes import DatabaseConfiguration
logger = logging.getLogger(__name__.split(".")[-1])
@@ -33,5 +33,5 @@ class DatabaseService:
self._users: AsyncCollection = self._database["users"]
await init_beanie(
database=self._database,
document_models=[User, Transaction]
document_models=[User, Transaction, Ticket]
)