Skip to content

Commit b2a62bf

Browse files
committed
feat(upload): first
0 parents  commit b2a62bf

File tree

2 files changed

+461
-0
lines changed

2 files changed

+461
-0
lines changed

paypal.py

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import os
2+
from typing import Any
3+
from typing import Dict
4+
from typing import Optional
5+
6+
import requests
7+
from requests.auth import HTTPBasicAuth
8+
9+
10+
class PayPalAPI:
11+
"""
12+
A simple library for PayPal REST API to manage subscriptions with variable pricing.
13+
"""
14+
15+
def __init__(self):
16+
self.client_id = os.getenv("PAYPAL_CLIENT_ID")
17+
self.client_secret = os.getenv("PAYPAL_CLIENT_SECRET")
18+
self.base_url = "https://api.sandbox.paypal.com" if os.getenv("PAYPAL_SANDBOX", "True") == True else "https://api.paypal.com"
19+
self.access_token = self._get_access_token()
20+
self.headers = {"Authorization": f"Bearer {self.access_token}", "Content-Type": "application/json"}
21+
self.plan_id = ""
22+
23+
def _make_request(self, url: str, method: str, **kwargs) -> Any:
24+
response = requests.request(method, url, **kwargs)
25+
response.raise_for_status()
26+
return response.json()
27+
28+
def _get_access_token(self) -> str:
29+
"""
30+
Get an access token from PayPal API.
31+
32+
Returns:
33+
str: Access token.
34+
"""
35+
url = f"{self.base_url}/v1/oauth2/token"
36+
headers = {"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"}
37+
data = {"grant_type": "client_credentials"}
38+
39+
response = requests.post(url, headers=headers, data=data, auth=HTTPBasicAuth(self.client_id, self.client_secret))
40+
response.raise_for_status()
41+
return response.json()["access_token"]
42+
43+
def subscription_exists(self, subscription_id: str) -> bool:
44+
"""
45+
Check if a subscription exists in PayPal and return its details if it does.
46+
47+
Args:
48+
subscription_id (str): Subscription ID.
49+
50+
Returns:
51+
Optional[Dict[str, Bool]]: Subscription details if the subscription exists, False otherwise.
52+
"""
53+
url = f"{self.base_url}/v1/billing/subscriptions/{subscription_id}"
54+
response = requests.get(url, headers=self.headers)
55+
if os.getenv("DEBUG", False):
56+
print(f"Check subscription {response.status_code} for {subscription_id}.")
57+
58+
if response.status_code == 200:
59+
_response = response.json()
60+
self.plan_id = _response.get("plan_id")
61+
return _response
62+
elif response.status_code in [400, 404]:
63+
return False
64+
65+
response.raise_for_status()
66+
return False
67+
68+
def verify_paypal_response(self, token: str, subscription_id: str) -> Dict[str, Any]:
69+
"""
70+
Verify PayPal response by checking the subscription details.
71+
72+
Args:
73+
token (str): PayPal transaction token.
74+
subscription_id (str): PayPal Payer ID.
75+
76+
Returns:
77+
Dict[str, Any]: Verification result.
78+
"""
79+
if not token or not subscription_id:
80+
return {"status": "error", "message": "Token or subscription_id missing"}
81+
82+
try:
83+
subscription_details = self.subscription_exists(token)
84+
if subscription_details == False:
85+
return {"status": "error", "message": "Subscription check failed"}
86+
87+
if subscription_details.get("id") != token:
88+
return {"status": "error", "message": "Token does not match subscription"}
89+
90+
subscriber_info = subscription_details.get("subscriber", {})
91+
stored_payer_id = subscriber_info.get("subscription_id")
92+
93+
if stored_payer_id and stored_payer_id != subscription_id:
94+
return {"status": "error", "message": "subscription_id does not match"}
95+
96+
status = subscription_details.get("status")
97+
if os.getenv("DEBUG", False):
98+
if status == "ACTIVE":
99+
print(f"Subscription {token} is active.")
100+
elif status == "CANCELLED":
101+
print(f"Subscription {token} is cancelled.")
102+
else:
103+
print(f"Subscription {token} status: {status}.")
104+
105+
return {
106+
"status": "success",
107+
"subscription_status": subscription_details.get("status"),
108+
"payer_email": subscriber_info.get("email_address")
109+
}
110+
except requests.exceptions.RequestException as e:
111+
return {"status": "error", "message": f"PayPal API error: {e}"}
112+
113+
def create_product(self, name: str, description: str, type_: str = "SERVICE", category: str = "SOFTWARE") -> Dict[str, Any]:
114+
"""
115+
Create a product for subscription.
116+
117+
Args:
118+
name (str): Product name.
119+
description (str): Product description.
120+
type_ (str): Product type (default is "SERVICE").
121+
category (str): Product category (default is "SOFTWARE").
122+
123+
Returns:
124+
Dict[str, Any]: API response with product details.
125+
"""
126+
product_data = {
127+
"name": name,
128+
"description": description,
129+
"type": type_,
130+
"category": category
131+
}
132+
url = f"{self.base_url}/v1/catalogs/products"
133+
return self._make_request(url=url, method="POST", json=product_data, headers=self.headers)
134+
135+
def create_plan(self, product_id: str, name: str, description: str, price: str, currency: str = "EUR") -> Dict[str, Any]:
136+
"""
137+
Create a subscription plan.
138+
139+
Args:
140+
product_id (str): Product ID.
141+
name (str): Plan name.
142+
description (str): Plan description.
143+
price (str): Plan price.
144+
currency (str): Currency code (default is "EUR").
145+
146+
Returns:
147+
Dict[str, Any]: API response with plan details.
148+
"""
149+
data = {
150+
"product_id": product_id,
151+
"name": name,
152+
"description": description,
153+
"billing_cycles": [
154+
{
155+
"frequency": {"interval_unit": "WEEK", "interval_count": 1},
156+
"tenure_type": "REGULAR",
157+
"sequence": 1,
158+
"total_cycles": 0,
159+
"pricing_scheme": {"fixed_price": {"value": price, "currency_code": currency}}
160+
}
161+
],
162+
"payment_preferences": {
163+
"auto_bill_outstanding": True,
164+
"setup_fee_failure_action": "CONTINUE",
165+
"payment_failure_threshold": 3
166+
}
167+
}
168+
url = f"{self.base_url}/v1/billing/plans"
169+
return self._make_request(url=url, method="POST", json=data, headers=self.headers)
170+
171+
def update_subscription_price(self, subscription_id: str, new_price: str, currency: str = "EUR") -> Dict[str, Any]:
172+
"""
173+
Update the subscription price.
174+
175+
Args:
176+
subscription_id (str): Subscription ID.
177+
new_price (str): New subscription price.
178+
currency (str): Currency code (default is "EUR").
179+
180+
Returns:
181+
Dict[str, Any]: API response with updated subscription details.
182+
"""
183+
url = f"{self.base_url}/v1/billing/subscriptions/{subscription_id}/revise"
184+
data = {
185+
"plan_id": self.plan_id,
186+
"billing_cycles": [
187+
{
188+
"frequency": {"interval_unit": "WEEK", "interval_count": 1},
189+
"tenure_type": "REGULAR",
190+
"sequence": 1,
191+
"pricing_scheme": {"fixed_price": {"value": new_price, "currency_code": currency}}
192+
}
193+
]
194+
}
195+
return self._make_request(url=url, method="POST", json=data, headers=self.headers)
196+
197+
def create_subscription(self, plan_id: str, subscriber_email: str, return_url: str, cancel_url: str) -> Dict[str, Any]:
198+
"""
199+
Create a new subscription.
200+
201+
Args:
202+
plan_id (str): Plan ID.
203+
subscriber_email (str): Subscriber's email.
204+
return_url (str): URL to redirect to after the subscriber approves the subscription.
205+
cancel_url (str): URL to redirect to if the subscriber cancels the subscription.
206+
207+
Returns:
208+
Dict[str, Any]: API response with subscription details.
209+
"""
210+
data = {
211+
"plan_id": plan_id,
212+
"subscriber": {
213+
"email_address": subscriber_email
214+
},
215+
"application_context": {
216+
"return_url": return_url,
217+
"cancel_url": cancel_url
218+
}
219+
}
220+
221+
url = f"{self.base_url}/v1/billing/subscriptions"
222+
return self._make_request(url=url, method="POST", json=data, headers=self.headers)
223+
224+
def suspend_subscription(self, subscription_id: str) -> Dict[str, Any]:
225+
"""
226+
Suspend a subscription by its ID.
227+
228+
Args:
229+
subscription_id (str): Subscription ID to suspend.
230+
231+
Returns:
232+
Dict[str, Any]: API response with suspension details.
233+
"""
234+
url = f"{self.base_url}/v1/billing/subscriptions/{subscription_id}/suspend"
235+
response = requests.post(url, headers=self.headers)
236+
237+
if response.status_code == 204:
238+
return {"status": "success", "message": "Subscription suspended successfully"}
239+
elif response.status_code == 404:
240+
return {"status": "error", "message": "Subscription not found"}
241+
elif response.status_code == 422:
242+
return {"status": "error", "message": "Subscription already suspended"}
243+
else:
244+
response.raise_for_status()
245+
return {"status": "error", "message": "Failed to suspend subscription"}
246+
247+
def create_or_update_subscription(self, identifier: str, name: str = "", description: str = "", price: Optional[str] = None, currency: str = "EUR", subscriber_email: Optional[str] = None, return_url: Optional[str] = None, cancel_url: Optional[str] = None) -> Dict[str, Any]:
248+
"""
249+
Create a new subscription or update an existing one.
250+
251+
Args:
252+
identifier (str): Unique identifier for subscription.
253+
name (Optional[str]): Name of the subscription product/plan.
254+
description (Optional[str]): Description of the subscription product/plan.
255+
price (Optional[str]): Price for the subscription.
256+
currency (str): Currency for the subscription (default is "USD").
257+
subscriber_email (Optional[str]): Subscriber email (required for new subscription).
258+
return_url (Optional[str]): Return URL for approval (required for new subscription).
259+
cancel_url (Optional[str]): Cancel URL (required for new subscription).
260+
261+
Returns:
262+
Dict[str, Any]: API response with subscription details.
263+
"""
264+
price = f"{price:.2f}"
265+
if not price or not subscriber_email or not return_url or not cancel_url:
266+
raise ValueError("Missing parameters required for subscription creation or update.")
267+
268+
if self.subscription_exists(identifier) != False:
269+
updated_subscription = self.update_subscription_price(subscription_id=identifier, new_price=price, currency=currency)
270+
if os.getenv("DEBUG", False):
271+
print(f"Updated subscription {identifier} successfully.")
272+
return updated_subscription
273+
else:
274+
if os.getenv("DEBUG", False):
275+
print(f"Subscription {identifier} not found. Creating a new subscription.")
276+
product = self.create_product(name=name, description=description)
277+
plan = self.create_plan(product_id=product["id"], name=name, description=description, price=price, currency=currency)
278+
new_subscription = self.create_subscription(plan_id=plan["id"], subscriber_email=subscriber_email, return_url=return_url, cancel_url=cancel_url)
279+
return new_subscription

0 commit comments

Comments
 (0)