Passed
Push — main ( 315d12...089b24 )
by Jochen
02:24
created

_parse_paypal_order_details()   A

Complexity

Conditions 1

Size

Total Lines 4
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
nop 1
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
"""
2
byceps.blueprints.site.shop.payment.paypal.views
3
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
5
:Copyright: 2020-2024 Jan Korneffel, Jochen Kupperschmidt
6
:License: Revised BSD (see `LICENSE` file for details)
7
"""
8
9
from dataclasses import dataclass
10
from uuid import UUID
11
12
from flask import abort, current_app, g, jsonify, request
13
from paypalcheckoutsdk.orders import OrdersGetRequest
14
from paypalhttp import HttpError, HttpResponse
15
from pydantic import BaseModel, ValidationError
16
17
from byceps.paypal import paypal
18
from byceps.services.shop.order import order_command_service, order_service
19
from byceps.services.shop.order.email import order_email_service
20
from byceps.services.shop.order.models.order import Order
21
from byceps.signals import shop as shop_signals
22
from byceps.util.framework.blueprint import create_blueprint
23
from byceps.util.views import create_empty_json_response
24
25
26
blueprint = create_blueprint('shop_payment_paypal', __name__)
27
28
29
class CapturePayPalRequest(BaseModel):
30
    shop_order_id: UUID
31
    paypal_order_id: str
32
33
34
@dataclass(frozen=True)
35
class PayPalOrderDetails:
36
    id: str
37
    transaction_id: str
38
39
40
@blueprint.post('/capture')
41
def capture_transaction():
42
    """Reconcile PayPal transaction."""
43
    if not g.user.authenticated:
44
        return create_empty_json_response(403)
45
46
    req = _parse_request()
47
48
    order = order_service.find_order(req.shop_order_id)
49
    if not order or not order.is_open:
50
        return create_empty_json_response(400)
51
52
    request = OrdersGetRequest(req.paypal_order_id)
53
    try:
54
        response = paypal.client.execute(request)
55
    except HttpError as e:
56
        current_app.logger.error(
57
            'PayPal API returned status code %d for paypal_order_id = %s, shop_order_id = %s: %s',
58
            e.status_code,
59
            req.paypal_order_id,
60
            order.id,
61
            e.message,
62
        )
63
        return create_empty_json_response(400)
64
65
    paypal_order_details = _parse_paypal_order_details(response)
66
67
    if not _check_transaction_against_order(response, order):
68
        current_app.logger.error(
69
            'PayPal order %s failed verification against shop order %s',
70
            req.paypal_order_id,
71
            order.id,
72
        )
73
        return create_empty_json_response(400)
74
75
    _mark_order_as_paid(order, paypal_order_details)
76
77
    return jsonify({'status': 'OK'})
78
79
80
def _parse_request() -> CapturePayPalRequest:
81
    try:
82
        return CapturePayPalRequest.model_validate(request.get_json())
83
    except ValidationError as e:
84
        abort(400, e.json())
85
86
87
def _parse_paypal_order_details(response: HttpResponse) -> PayPalOrderDetails:
88
    return PayPalOrderDetails(
89
        id=response.result.id,
90
        transaction_id=_extract_transaction_id(response),
91
    )
92
93
94
def _extract_transaction_id(response: HttpResponse) -> str:
95
    purchase_unit = response.result.purchase_units[0]
96
97
    completed_captures = filter(
98
        lambda c: c.status in ('COMPLETED', 'PENDING'),
99
        purchase_unit.payments.captures,
100
    )
101
102
    transaction = next(completed_captures)
103
104
    return transaction.id
105
106
107
def _check_transaction_against_order(
108
    response: HttpResponse, order: Order
109
) -> bool:
110
    purchase_unit = response.result.purchase_units[0]
111
112
    return (
113
        response.result.status == 'COMPLETED'
114
        and purchase_unit.amount.currency_code == 'EUR'
115
        and purchase_unit.amount.value == str(order.total_amount)
116
        and purchase_unit.invoice_id == order.order_number
117
    )
118
119
120
def _mark_order_as_paid(
121
    order: Order, paypal_order_details: PayPalOrderDetails
122
) -> None:
123
    additional_payment_data = {
124
        'paypal_order_id': paypal_order_details.id,
125
        'paypal_transaction_id': paypal_order_details.transaction_id,
126
    }
127
128
    paid_order, event = order_command_service.mark_order_as_paid(
129
        order.id,
130
        'paypal',
131
        g.user,
132
        additional_payment_data=additional_payment_data,
133
    ).unwrap()
134
135
    order_email_service.send_email_for_paid_order_to_orderer(paid_order)
136
137
    shop_signals.order_paid.send(None, event=event)
138