312 lines
11 KiB
Python
312 lines
11 KiB
Python
from datetime import date
|
||
|
||
import structlog
|
||
from django.conf import settings
|
||
from django.http import JsonResponse
|
||
from rest_framework.decorators import api_view, permission_classes
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from sentry_sdk import capture_exception
|
||
|
||
from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
|
||
from vbv_lernwelt.learnpath.consts import COURSE_PROFILE_ALL_ID
|
||
from vbv_lernwelt.learnpath.models import CourseProfile
|
||
from vbv_lernwelt.notify.email.email_services import EmailTemplate, send_email
|
||
from vbv_lernwelt.shop.const import (
|
||
VV_DE_PRODUCT_SKU,
|
||
VV_FR_PRODUCT_SKU,
|
||
VV_IT_PRODUCT_SKU,
|
||
VV_PRODUCT_NUMBER,
|
||
)
|
||
from vbv_lernwelt.shop.models import CheckoutInformation, CheckoutState, Product
|
||
from vbv_lernwelt.shop.services import (
|
||
create_context_data_log,
|
||
datatrans_state_to_checkout_state,
|
||
get_payment_url,
|
||
init_datatrans_transaction,
|
||
InitTransactionException,
|
||
is_signature_valid,
|
||
)
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
PRODUCT_SKU_TO_COURSE_SESSION_ID = {
|
||
# CourseSession IDs PROD/STAGING
|
||
VV_DE_PRODUCT_SKU: 1, # vv-de
|
||
VV_FR_PRODUCT_SKU: 2, # vv-fr
|
||
VV_IT_PRODUCT_SKU: 3, # vv-it
|
||
}
|
||
|
||
|
||
@api_view(["POST"])
|
||
def transaction_webhook(request):
|
||
"""IMPORTANT: This is not called for timed out transactions!"""
|
||
|
||
logger.info("Webhook: Datatrans called transaction webhook", body=request.body)
|
||
|
||
request._request.security_request_logging = "datatrans_webhook"
|
||
request._request.type = "datatrans"
|
||
request._request.log_additional_json_data = request.data
|
||
|
||
if not is_signature_valid(
|
||
signature=request.headers.get("Datatrans-Signature", ""),
|
||
payload=request.body,
|
||
):
|
||
logger.warning("Datatrans Transaction Webhook: Invalid Signature -> Ignored")
|
||
return JsonResponse({"status": "invalid signature"}, status=400)
|
||
|
||
transaction = request.data
|
||
transaction_id = transaction["transactionId"]
|
||
|
||
# keep webhook history (for debugging)
|
||
checkout_info = CheckoutInformation.objects.get(transaction_id=transaction_id)
|
||
checkout_info.webhook_history.append(transaction)
|
||
checkout_info.save(update_fields=["webhook_history"])
|
||
|
||
# update checkout state
|
||
checkout_state = datatrans_state_to_checkout_state(transaction["status"])
|
||
update_checkout_state(checkout_info=checkout_info, state=checkout_state)
|
||
|
||
# handle paid
|
||
if checkout_state == CheckoutState.PAID:
|
||
create_vv_course_session_user(checkout_info=checkout_info)
|
||
|
||
return JsonResponse({"status": "ok"})
|
||
|
||
|
||
@api_view(["POST"])
|
||
@permission_classes([IsAuthenticated])
|
||
def checkout_vv(request):
|
||
"""
|
||
Check-out for the Versicherungsvermittler products (vv-de, vv-fr, vv-it)
|
||
|
||
IMPORTANT: Even if we have an already ONGOING checkout,
|
||
we create a new one! This might seem a bit unintuitive,
|
||
but it's the advised way to handle it by Datatrans:
|
||
|
||
"Fehlverhalten des User können fast gar nicht abgefangen werden,
|
||
wichtig wäre aus eurer Sicht das ihr immer einen neuen INIT
|
||
schickt, wenn der User im Checkout ist und zum Beispiel
|
||
auf «Bezahlen» klickt. Um zum Beispiel White-screens
|
||
bei Browser Back redirections zu vermeiden."
|
||
|
||
"""
|
||
context_data, log = create_context_data_log(request, "checkout_vv")
|
||
|
||
sku = request.data["product"]
|
||
base_redirect_url = request.data["redirect_url"]
|
||
chosen_profile_id = request.data.get("chosen_profile", COURSE_PROFILE_ALL_ID)
|
||
|
||
log.info("Checkout requested: sku", user_id=request.user.id, sku=sku)
|
||
|
||
try:
|
||
product = Product.objects.get(sku=sku)
|
||
except Product.DoesNotExist:
|
||
return next_step_response(
|
||
url=checkout_error_url(
|
||
base_url=base_redirect_url,
|
||
product_sku=sku,
|
||
message=f"{sku}_product_sku_does_not_exist_needs_to_be_created_first",
|
||
),
|
||
)
|
||
|
||
try:
|
||
chosen_profile = CourseProfile.objects.get(id=chosen_profile_id)
|
||
except CourseProfile.DoesNotExist:
|
||
chosen_profile = CourseProfile.objects.get(id=COURSE_PROFILE_ALL_ID)
|
||
|
||
checkouts = CheckoutInformation.objects.filter(
|
||
user=request.user,
|
||
product_sku=sku,
|
||
)
|
||
|
||
# already paid successfully -> redirect to home
|
||
# any other case create a new checkout (see doc above)
|
||
if checkouts.filter(state=CheckoutState.PAID).exists():
|
||
return next_step_response(url="/")
|
||
|
||
with_cembra_byjuno_invoice = request.data.get("with_cembra_byjuno_invoice", False)
|
||
ip_address = request.META.get("REMOTE_ADDR")
|
||
email = request.user.email
|
||
|
||
request.user.set_increment_abacus_debitor_number(
|
||
disable_save="fakeapi" in settings.DATATRANS_API_ENDPOINT
|
||
)
|
||
|
||
address_data = request.data["address"]
|
||
country_code = address_data.pop("country_code")
|
||
address_data["country_id"] = country_code
|
||
|
||
organisation_country_code = "CH"
|
||
if "organisation_country_code" in address_data:
|
||
organisation_country_code = address_data.pop("organisation_country_code")
|
||
address_data["organisation_country_id"] = organisation_country_code
|
||
|
||
if "birth_date" in address_data and address_data["birth_date"]:
|
||
address_data["birth_date"] = date.fromisoformat(address_data["birth_date"])
|
||
|
||
checkout_info = CheckoutInformation.objects.create(
|
||
user=request.user,
|
||
state=CheckoutState.ONGOING,
|
||
# product
|
||
product_sku=sku,
|
||
product_price=product.price,
|
||
product_name=product.name,
|
||
product_description=product.description,
|
||
email=email,
|
||
ip_address=ip_address,
|
||
cembra_byjuno_invoice=with_cembra_byjuno_invoice,
|
||
device_fingerprint_session_key=request.data.get(
|
||
"device_fingerprint_session_key", ""
|
||
),
|
||
# address
|
||
chosen_profile=chosen_profile,
|
||
**request.data["address"],
|
||
)
|
||
|
||
checkout_info.set_increment_abacus_order_id()
|
||
|
||
refno2 = f"{request.user.abacus_debitor_number}_{VV_PRODUCT_NUMBER}"
|
||
|
||
try:
|
||
datatrans_customer_data = None
|
||
datatrans_int_data = None
|
||
if with_cembra_byjuno_invoice:
|
||
# see https://api-reference.datatrans.ch/#tag/v1transactions as reference
|
||
datatrans_customer_data = {
|
||
"firstName": request.user.first_name,
|
||
"lastName": request.user.last_name,
|
||
"id": request.user.abacus_debitor_number,
|
||
"street": f'{request.data["address"]["street"]} {request.data["address"]["street_number"]}',
|
||
"city": request.data["address"]["city"],
|
||
"zipCode": request.data["address"]["postal_code"],
|
||
"country": request.data["address"]["country_id"],
|
||
"phone": request.data["address"]["phone_number"],
|
||
"email": email,
|
||
"birthDate": str(request.data["address"]["birth_date"]),
|
||
"language": request.user.language,
|
||
"ipAddress": ip_address,
|
||
"type": "P",
|
||
}
|
||
datatrans_int_data = {
|
||
"subtype": "INVOICE",
|
||
"riskOwner": "IJ",
|
||
"repaymentType": 3,
|
||
"deviceFingerprintId": request.data["device_fingerprint_session_key"],
|
||
}
|
||
transaction_id = init_datatrans_transaction(
|
||
user=request.user,
|
||
refno=str(checkout_info.abacus_order_id),
|
||
amount_chf_centimes=product.price,
|
||
redirect_url_success=checkout_success_url(
|
||
base_url=base_redirect_url, product_sku=sku
|
||
),
|
||
redirect_url_error=checkout_error_url(
|
||
base_url=base_redirect_url, product_sku=sku
|
||
),
|
||
redirect_url_cancel=checkout_cancel_url(base_redirect_url),
|
||
webhook_url=webhook_url(base_redirect_url),
|
||
refno2=refno2,
|
||
datatrans_customer_data=datatrans_customer_data,
|
||
datatrans_int_data=datatrans_int_data,
|
||
context_data=context_data,
|
||
with_cembra_byjuno_invoice=with_cembra_byjuno_invoice,
|
||
)
|
||
except InitTransactionException as e:
|
||
checkout_info.state = CheckoutState.FAILED.value
|
||
checkout_info.save()
|
||
if not settings.DEBUG:
|
||
log.error("Transaction initiation failed", exc_info=True, error=str(e))
|
||
capture_exception(e)
|
||
return next_step_response(
|
||
url=checkout_error_url(
|
||
base_url=base_redirect_url,
|
||
product_sku=sku,
|
||
),
|
||
)
|
||
|
||
checkout_info.transaction_id = transaction_id
|
||
checkout_info.save()
|
||
|
||
return next_step_response(url=get_payment_url(transaction_id))
|
||
|
||
|
||
def update_checkout_state(checkout_info: CheckoutInformation, state: CheckoutState):
|
||
checkout_info.state = state.value
|
||
checkout_info.save(update_fields=["state"])
|
||
|
||
|
||
def send_vv_welcome_email(checkout_info: CheckoutInformation):
|
||
course_names = {
|
||
VV_DE_PRODUCT_SKU: "Versicherungsvermittler/-in VBV (Deutsch)",
|
||
VV_FR_PRODUCT_SKU: "Intermédiaire d’assurance AFA (Français)",
|
||
VV_IT_PRODUCT_SKU: "Intermediario/a assicurativo/a AFA (Italiano)",
|
||
}
|
||
|
||
send_email(
|
||
recipient_email=checkout_info.user.email,
|
||
template=EmailTemplate.WELCOME_MAIL_VV,
|
||
template_data={
|
||
"course": course_names[checkout_info.product_sku],
|
||
"target_url": "https://my.vbv-afa.ch/",
|
||
"name": f"{checkout_info.first_name} {checkout_info.last_name}",
|
||
"private_street": f"{checkout_info.street} {checkout_info.street_number}",
|
||
"private_city": f"{checkout_info.country_id}-{checkout_info.postal_code} {checkout_info.city}",
|
||
"company_name": checkout_info.organisation_detail_name,
|
||
"company_street": f"{checkout_info.organisation_street} {checkout_info.organisation_street_number}",
|
||
"company_city": f"{checkout_info.organisation_country_id}-{checkout_info.organisation_postal_code} {checkout_info.organisation_city}",
|
||
},
|
||
template_language=checkout_info.user.language,
|
||
fail_silently=True,
|
||
)
|
||
|
||
|
||
def create_vv_course_session_user(checkout_info: CheckoutInformation):
|
||
logger.info("Creating VV course session user", user_id=checkout_info.user_id)
|
||
|
||
_, created = CourseSessionUser.objects.get_or_create(
|
||
user=checkout_info.user,
|
||
role=CourseSessionUser.Role.MEMBER,
|
||
chosen_profile=checkout_info.chosen_profile,
|
||
course_session=CourseSession.objects.get(
|
||
id=PRODUCT_SKU_TO_COURSE_SESSION_ID[checkout_info.product_sku]
|
||
),
|
||
# chosen_profile=bla,
|
||
)
|
||
|
||
if created:
|
||
logger.info("VV course session user created", user_id=checkout_info.user_id)
|
||
send_vv_welcome_email(checkout_info)
|
||
|
||
|
||
def next_step_response(
|
||
url: str,
|
||
) -> JsonResponse:
|
||
return JsonResponse(
|
||
{
|
||
"next_step_url": url,
|
||
},
|
||
)
|
||
|
||
|
||
def webhook_url(base_url: str) -> str:
|
||
return f"{base_url}/api/shop/transaction/webhook/"
|
||
|
||
|
||
def checkout_error_url(
|
||
base_url: str, product_sku: str, message: str | None = None
|
||
) -> str:
|
||
url = f"{base_url}/onboarding/{product_sku}/checkout/address?error"
|
||
|
||
if message:
|
||
url += f"&message={message}"
|
||
|
||
return url
|
||
|
||
|
||
def checkout_cancel_url(base_url: str) -> str:
|
||
return f"{base_url}/"
|
||
|
||
|
||
def checkout_success_url(product_sku: str, base_url: str = "") -> str:
|
||
return f"{base_url}/onboarding/{product_sku}/checkout/complete"
|