Skip to content

Commit 81d96a8

Browse files
Use new HelloAsso SDK (#706)
### Description Replace the previous handmade api wrapper by the new official sdk --------- Co-authored-by: Armand Didierjean <[email protected]>
1 parent ef81592 commit 81d96a8

File tree

7 files changed

+334
-89
lines changed

7 files changed

+334
-89
lines changed

app/core/payment/endpoints_payment.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@
33
from typing import cast
44

55
from fastapi import APIRouter, Depends, HTTPException, Request
6-
from helloasso_api_wrapper.models.api_notifications import (
7-
ApiNotificationType,
8-
NotificationResultContent,
6+
from helloasso_python.models.hello_asso_api_v5_models_api_notifications_api_notification_type import (
7+
HelloAssoApiV5ModelsApiNotificationsApiNotificationType,
98
)
109
from pydantic import TypeAdapter, ValidationError
1110
from sqlalchemy.ext.asyncio import AsyncSession
1211

1312
from app.core.core_module_list import core_module_list
1413
from app.core.payment import cruds_payment, models_payment, schemas_payment
14+
from app.core.payment.types_payment import (
15+
NotificationResultContent,
16+
)
1517
from app.dependencies import get_db
1618
from app.modules.module_list import module_list
1719
from app.types.module import CoreModule
@@ -43,7 +45,7 @@ async def webhook(
4345
validated_content = type_adapter.validate_python(
4446
await request.json(),
4547
)
46-
content = cast(NotificationResultContent, validated_content)
48+
content = cast("NotificationResultContent", validated_content)
4749
if content.metadata:
4850
checkout_metadata = (
4951
schemas_payment.HelloAssoCheckoutMetadata.model_validate(
@@ -60,9 +62,15 @@ async def webhook(
6062
status_code=400,
6163
detail="Could not validate the webhook body",
6264
)
63-
if content.eventType == ApiNotificationType.Order:
65+
if (
66+
content.eventType
67+
== HelloAssoApiV5ModelsApiNotificationsApiNotificationType.ORDER
68+
):
6469
pass
65-
if content.eventType == ApiNotificationType.Payment:
70+
if (
71+
content.eventType
72+
== HelloAssoApiV5ModelsApiNotificationsApiNotificationType.PAYMENT
73+
):
6674
# We may receive the webhook multiple times, we only want to save a CheckoutPayment
6775
# in the database the first time
6876
existing_checkout_payment_model = (

app/core/payment/payment_tool.py

Lines changed: 153 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,113 @@
11
import logging
22
import uuid
3+
from datetime import UTC, datetime
4+
from typing import TYPE_CHECKING
35

4-
from helloasso_api_wrapper import HelloAssoAPIWrapper
5-
from helloasso_api_wrapper.exceptions import ApiV5BadRequest
6-
from helloasso_api_wrapper.models.carts import (
7-
CheckoutPayer,
8-
InitCheckoutBody,
9-
InitCheckoutResponse,
6+
from authlib.integrations.requests_client import OAuth2Session
7+
from helloasso_python.api.checkout_api import CheckoutApi
8+
from helloasso_python.api.paiements_api import PaiementsApi
9+
from helloasso_python.api_client import ApiClient
10+
from helloasso_python.configuration import Configuration
11+
from helloasso_python.models.hello_asso_api_v5_models_carts_checkout_payer import (
12+
HelloAssoApiV5ModelsCartsCheckoutPayer,
13+
)
14+
from helloasso_python.models.hello_asso_api_v5_models_carts_init_checkout_body import (
15+
HelloAssoApiV5ModelsCartsInitCheckoutBody,
1016
)
1117
from sqlalchemy.ext.asyncio import AsyncSession
1218

1319
from app.core.payment import cruds_payment, models_payment, schemas_payment
1420
from app.core.users import schemas_users
1521
from app.core.utils import security
1622
from app.core.utils.config import Settings
17-
from app.types.exceptions import PaymentToolCredentialsNotSetException
23+
from app.types.exceptions import (
24+
MissingHelloAssoCheckoutIdError,
25+
PaymentToolCredentialsNotSetException,
26+
)
27+
28+
if TYPE_CHECKING:
29+
from helloasso_python.models.hello_asso_api_v5_models_carts_init_checkout_response import (
30+
HelloAssoApiV5ModelsCartsInitCheckoutResponse,
31+
)
1832

1933
hyperion_error_logger = logging.getLogger("hyperion.error")
2034

2135

2236
class PaymentTool:
23-
hello_asso: HelloAssoAPIWrapper | None
37+
_access_token: str | None = None
38+
_refresh_token: str | None = None
39+
_access_token_expiry: int | None = None
40+
41+
_auth_client: OAuth2Session | None = None
42+
43+
_hello_asso_api_base: str | None = None
2444

2545
def __init__(self, settings: Settings):
2646
if (
2747
settings.HELLOASSO_API_BASE
2848
and settings.HELLOASSO_CLIENT_ID
2949
and settings.HELLOASSO_CLIENT_SECRET
3050
):
31-
self.hello_asso = HelloAssoAPIWrapper(
32-
api_base=settings.HELLOASSO_API_BASE,
33-
client_id=settings.HELLOASSO_CLIENT_ID,
34-
client_secret=settings.HELLOASSO_CLIENT_SECRET,
35-
timeout=60,
51+
self._hello_asso_api_base = settings.HELLOASSO_API_BASE
52+
self._auth_client = OAuth2Session(
53+
settings.HELLOASSO_CLIENT_ID,
54+
settings.HELLOASSO_CLIENT_SECRET,
55+
token_endpoint="https://"
56+
+ settings.HELLOASSO_API_BASE
57+
+ "/oauth2/token",
3658
)
3759
else:
3860
hyperion_error_logger.warning(
3961
"HelloAsso API credentials are not set, payment won't be available",
4062
)
41-
self.hello_asso = None
63+
64+
def get_access_token(self) -> str:
65+
if not self._auth_client:
66+
raise PaymentToolCredentialsNotSetException
67+
# If the access token is not set, we get one
68+
if self._access_token is None:
69+
try:
70+
tokens = self._auth_client.fetch_token(grant_type="client_credentials")
71+
except Exception:
72+
hyperion_error_logger.exception(
73+
"Payment: failed to get HelloAsso access token",
74+
)
75+
raise
76+
self._access_token = tokens["access_token"]
77+
self._refresh_token = tokens["refresh_token"]
78+
self._access_token_expiry = tokens["expires_at"]
79+
# If we have a token but it's expired, we need to refresh it
80+
elif self._access_token_expiry is None or self._access_token_expiry < int(
81+
datetime.now(UTC).timestamp(),
82+
):
83+
# Token is expired, we need to refresh it
84+
try:
85+
tokens = self._auth_client.refresh_token(
86+
refresh_token=self._refresh_token,
87+
)
88+
except Exception:
89+
hyperion_error_logger.exception(
90+
"Payment: failed to refresh HelloAsso access token, getting a new one",
91+
)
92+
self._access_token = None
93+
self._refresh_token = None
94+
self._access_token_expiry = None
95+
return self.get_access_token()
96+
97+
return self._access_token
98+
99+
def get_hello_asso_configuration(self) -> Configuration:
100+
"""
101+
Get a valid access token and construct an HelloAsso API configuration object
102+
"""
103+
access_token = self.get_access_token()
104+
if self._hello_asso_api_base is None:
105+
raise PaymentToolCredentialsNotSetException
106+
return Configuration(
107+
host="https://" + self._hello_asso_api_base + "/v5",
108+
access_token=access_token,
109+
retries=3,
110+
)
42111

43112
def is_payment_available(self) -> bool:
44113
"""
@@ -47,7 +116,13 @@ def is_payment_available(self) -> bool:
47116
You should always call this method before trying to init a checkout
48117
If payment is not available, you usually should raise an HTTP Exception explaining that payment is disabled because the API credentials are not configured in settings.
49118
"""
50-
return self.hello_asso is not None
119+
return (
120+
self._auth_client is not None
121+
and self._hello_asso_api_base is not None
122+
and self._access_token is not None
123+
and self._access_token_expiry is not None
124+
and self._refresh_token is not None
125+
)
51126

52127
async def init_checkout(
53128
self,
@@ -76,17 +151,16 @@ async def init_checkout(
76151
payment_url: you need to redirect the user to this payment page
77152
78153
This method use HelloAsso API. It may raise exceptions if HA checkout initialization fails.
79-
Exceptions can be imported from `helloasso_api_wrapper.exceptions`
154+
Exceptions can be imported from `helloasso_python` package.
80155
"""
81-
if not self.hello_asso:
82-
raise PaymentToolCredentialsNotSetException
156+
configuration = self.get_hello_asso_configuration()
83157

84158
# We want to ensure that any error is logged, even if modules tries to try/except this method
85159
# Thus we catch any exception and log it, then reraise it
86160
try:
87-
payer: CheckoutPayer | None = None
161+
payer: HelloAssoApiV5ModelsCartsCheckoutPayer | None = None
88162
if payer_user:
89-
payer = CheckoutPayer(
163+
payer = HelloAssoApiV5ModelsCartsCheckoutPayer(
90164
firstName=payer_user.firstname,
91165
lastName=payer_user.name,
92166
email=payer_user.email,
@@ -96,14 +170,14 @@ async def init_checkout(
96170
checkout_model_id = uuid.uuid4()
97171
secret = security.generate_token(nbytes=12)
98172

99-
init_checkout_body = InitCheckoutBody(
100-
totalAmount=checkout_amount,
101-
initialAmount=checkout_amount,
102-
itemName=checkout_name,
103-
backUrl=redirection_uri,
104-
errorUrl=redirection_uri,
105-
returnUrl=redirection_uri,
106-
containsDonation=False,
173+
init_checkout_body = HelloAssoApiV5ModelsCartsInitCheckoutBody(
174+
total_amount=checkout_amount,
175+
initial_amount=checkout_amount,
176+
item_name=checkout_name,
177+
back_url=redirection_uri,
178+
error_url=redirection_uri,
179+
return_url=redirection_uri,
180+
contains_donation=False,
107181
payer=payer,
108182
metadata=schemas_payment.HelloAssoCheckoutMetadata(
109183
secret=secret,
@@ -113,25 +187,33 @@ async def init_checkout(
113187

114188
# TODO: if payment fail, we can retry
115189
# then try without the payer infos
116-
response: InitCheckoutResponse
117-
try:
118-
response = self.hello_asso.checkout_intents_management.init_a_checkout(
119-
helloasso_slug,
120-
init_checkout_body,
121-
)
122-
except ApiV5BadRequest:
123-
# We know that HelloAsso may refuse some payer infos, like using the firstname "test"
124-
# Even when prefilling the payer infos,the user will be able to edit them on the payment page,
125-
# so we can safely retry without the payer infos
126-
hyperion_error_logger.exception(
127-
f"Payment: failed to init a checkout with HA for module {module} and name {checkout_name}. Retrying without payer infos",
128-
)
190+
response: HelloAssoApiV5ModelsCartsInitCheckoutResponse
191+
with ApiClient(configuration) as api_client:
192+
checkout_api = CheckoutApi(api_client)
193+
try:
194+
response = checkout_api.organizations_organization_slug_checkout_intents_post(
195+
helloasso_slug,
196+
init_checkout_body,
197+
)
198+
except Exception:
199+
# We know that HelloAsso may refuse some payer infos, like using the firstname "test"
200+
# Even when prefilling the payer infos,the user will be able to edit them on the payment page,
201+
# so we can safely retry without the payer infos
202+
hyperion_error_logger.exception(
203+
f"Payment: failed to init a checkout with HA for module {module} and name {checkout_name}. Retrying without payer infos",
204+
)
129205

130-
init_checkout_body.payer = None
131-
response = self.hello_asso.checkout_intents_management.init_a_checkout(
132-
helloasso_slug,
133-
init_checkout_body,
206+
init_checkout_body.payer = None
207+
response = checkout_api.organizations_organization_slug_checkout_intents_post(
208+
helloasso_slug,
209+
init_checkout_body,
210+
)
211+
212+
if response.id is None:
213+
hyperion_error_logger.error(
214+
f"Payment: failed to init a checkout with HA for module {module} and name {checkout_name}. No checkout id returned",
134215
)
216+
raise MissingHelloAssoCheckoutIdError() # noqa: TRY301
135217

136218
checkout_model = models_payment.Checkout(
137219
id=checkout_model_id,
@@ -146,7 +228,7 @@ async def init_checkout(
146228

147229
return schemas_payment.Checkout(
148230
id=checkout_model_id,
149-
payment_url=response.redirectUrl,
231+
payment_url=response.redirect_url,
150232
)
151233
except Exception:
152234
hyperion_error_logger.exception(
@@ -172,3 +254,28 @@ async def get_checkout(
172254
for payment in checkout_dict["payments"]
173255
]
174256
return schemas_payment.CheckoutComplete(**checkout_dict)
257+
258+
async def refund_payment(
259+
self,
260+
checkout_id: uuid.UUID,
261+
hello_asso_payment_id: int,
262+
amount: int,
263+
) -> None:
264+
"""
265+
Refund a payment
266+
"""
267+
configuration = self.get_hello_asso_configuration()
268+
269+
with ApiClient(configuration) as api_client:
270+
paiements_api = PaiementsApi(api_client)
271+
try:
272+
paiements_api.payments_payment_id_refund_post(
273+
payment_id=hello_asso_payment_id,
274+
send_refund_mail=True,
275+
amount=amount,
276+
)
277+
except Exception:
278+
hyperion_error_logger.exception(
279+
f"Payment: failed to refund payment {hello_asso_payment_id} for checkout {checkout_id}",
280+
)
281+
raise

0 commit comments

Comments
 (0)