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
|
|
|
|