Skip to content

Commit 9b2cd28

Browse files
committed
Fix: Add Polar.sh Support (closes #46)
1 parent 51bc7b8 commit 9b2cd28

1 file changed

Lines changed: 292 additions & 0 deletions

File tree

src/services/billing_service.py

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import os
2+
import logging
3+
from abc import ABC, abstractmethod
4+
5+
# Configure basic logging for the service
6+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
7+
logger = logging.getLogger(__name__)
8+
9+
# In a real application, you would import actual SDKs like 'stripe' and 'polar'
10+
# import stripe
11+
# import polar_sdk_client # Assuming a hypothetical Polar.sh SDK (e.g., polar.api)
12+
13+
# --- Constants for Provider Names ---
14+
PROVIDER_STRIPE = "stripe"
15+
PROVIDER_POLAR = "polar"
16+
17+
# --- Abstract Base Class for Payment Providers ---
18+
class BasePaymentProvider(ABC):
19+
"""
20+
Abstract base class defining the interface for all payment providers.
21+
Ensures a consistent API across different payment gateways.
22+
"""
23+
def __init__(self, api_key: str, **kwargs):
24+
self.api_key = api_key
25+
if not self.api_key:
26+
raise ValueError(f"{self.__class__.__name__} API key not provided.")
27+
self._initialize_client(**kwargs)
28+
29+
@abstractmethod
30+
def _initialize_client(self, **kwargs):
31+
"""
32+
Initializes the specific SDK client for the provider.
33+
"""
34+
pass
35+
36+
@abstractmethod
37+
def create_checkout_session(self, item_id: str, amount: int, currency: str, success_url: str, cancel_url: str, metadata: dict = None, **kwargs) -> dict:
38+
"""
39+
Creates a checkout session for the specified payment provider.
40+
Returns a dictionary containing session details, typically including a URL for redirection.
41+
"""
42+
pass
43+
44+
@abstractmethod
45+
def handle_webhook(self, payload: dict, signature: str = None) -> dict:
46+
"""
47+
Handles incoming webhooks from the payment provider.
48+
Returns a dictionary indicating the processing status.
49+
"""
50+
pass
51+
52+
# --- Stripe Payment Provider Implementation ---
53+
class StripeProvider(BasePaymentProvider):
54+
"""
55+
Concrete implementation for the Stripe payment gateway.
56+
"""
57+
def _initialize_client(self, **kwargs):
58+
# In a real scenario, uncomment this line:
59+
# stripe.api_key = self.api_key
60+
logger.info(f"Stripe service initialized (key: {self.api_key[:4]}...).")
61+
62+
def create_checkout_session(self, item_id: str, amount: int, currency: str, success_url: str, cancel_url: str, metadata: dict = None, **kwargs) -> dict:
63+
"""
64+
Creates a Stripe Checkout Session.
65+
"""
66+
# This is a dummy implementation. In a real scenario, you would use the Stripe SDK:
67+
# try:
68+
# checkout_session = stripe.checkout.Session.create(
69+
# line_items=[
70+
# {
71+
# 'price_data': {
72+
# 'currency': currency,
73+
# 'product_data': {
74+
# 'name': f'Product {item_id}',
75+
# },
76+
# 'unit_amount': amount,
77+
# },
78+
# 'quantity': 1,
79+
# }
80+
# ],
81+
# mode='payment', # or 'subscription'
82+
# success_url=success_url,
83+
# cancel_url=cancel_url,
84+
# metadata=metadata,
85+
# **kwargs # Allows passing additional Stripe-specific parameters
86+
# )
87+
# return {"provider": PROVIDER_STRIPE, "session_id": checkout_session.id, "url": checkout_session.url}
88+
# except stripe.error.StripeError as e:
89+
# logger.error(f"Error creating Stripe checkout session: {e}", exc_info=True)
90+
# raise
91+
92+
logger.info(f"DUMMY: Creating Stripe checkout session for item {item_id}, amount {amount} {currency}")
93+
return {
94+
"provider": PROVIDER_STRIPE,
95+
"session_id": f"cs_stripe_{item_id}_{amount}_{os.urandom(4).hex()}",
96+
"url": f"https://checkout.stripe.com/pay/{item_id}?session_id={os.urandom(8).hex()}", # Placeholder URL
97+
"metadata": metadata
98+
}
99+
100+
def handle_webhook(self, payload: dict, signature: str = None) -> dict:
101+
"""
102+
Handles Stripe webhook events.
103+
"""
104+
# In a real scenario, you would verify the signature and process the event:
105+
# try:
106+
# webhook_secret = os.environ.get("STRIPE_WEBHOOK_SECRET") # Or from config
107+
# if not webhook_secret:
108+
# raise ValueError("Stripe webhook secret not configured.")
109+
#
110+
# event = stripe.Webhook.construct_event(
111+
# payload, signature, webhook_secret
112+
# )
113+
# # Process event.type (e.g., 'checkout.session.completed', 'payment_intent.succeeded')
114+
# logger.info(f"Stripe webhook received: {event['type']} (ID: {event['id']})")
115+
# return {"status": "success", "provider": PROVIDER_STRIPE, "event_id": event['id'], "type": event['type']}
116+
# except ValueError as e:
117+
# logger.error(f"Invalid payload for Stripe webhook: {e}", exc_info=True)
118+
# raise
119+
# except stripe.error.SignatureVerificationError as e:
120+
# logger.error(f"Invalid signature for Stripe webhook: {e}", exc_info=True)
121+
# raise
122+
# except Exception as e:
123+
# logger.error(f"Unhandled error processing Stripe webhook: {e}", exc_info=True)
124+
# raise
125+
126+
logger.info(f"DUMMY: Handling Stripe webhook. Event type: {payload.get('type', 'unknown')}, ID: {payload.get('id', 'N/A')}")
127+
return {"status": "success", "provider": PROVIDER_STRIPE, "event_id": payload.get("id", "unknown"), "type": payload.get("type", "unknown_stripe_event")}
128+
129+
# --- Polar.sh Payment Provider Implementation ---
130+
class PolarProvider(BasePaymentProvider):
131+
"""
132+
Concrete implementation for the Polar.sh pledge/payment gateway.
133+
Polar.sh's model is typically more focused on pledges and funding open-source projects.
134+
"""
135+
def __init__(self, api_key: str, organization_id: str, **kwargs):
136+
self.organization_id = organization_id
137+
if not self.organization_id:
138+
raise ValueError("Polar.sh organization ID not provided.")
139+
super().__init__(api_key, **kwargs)
140+
141+
def _initialize_client(self, **kwargs):
142+
# In a real scenario, uncomment this line:
143+
# self.polar_client = polar_sdk_client.PolarClient(api_key=self.api_key)
144+
logger.info(f"Polar.sh service initialized (key: {self.api_key[:4]}..., org_id: {self.organization_id}).")
145+
146+
def create_checkout_session(self, item_id: str, amount: int, currency: str, success_url: str, cancel_url: str, metadata: dict = None, **kwargs) -> dict:
147+
"""
148+
Creates a Polar.sh checkout session (e.g., a pledge link or an invoice).
149+
Note: Polar.sh's API might have specific requirements for `item_id` (e.g., an issue ID or a campaign ID).
150+
"""
151+
# This is a dummy implementation. In a real scenario, you would use the Polar.sh SDK:
152+
# try:
153+
# # The exact API call would depend on whether you're creating a pledge,
154+
# # a one-time invoice, or linking to a specific funding goal.
155+
# # Example for a hypothetical pledge creation:
156+
# pledge_link = self.polar_client.pledges.create(
157+
# organization_id=self.organization_id,
158+
# amount=amount,
159+
# currency=currency,
160+
# success_url=success_url,
161+
# cancel_url=cancel_url,
162+
# description=f"Pledge for {item_id}", # item_id might map to a description
163+
# metadata=metadata,
164+
# **kwargs # Allows passing additional Polar.sh-specific parameters
165+
# )
166+
# return {"provider": PROVIDER_POLAR, "session_id": pledge_link.id, "url": pledge_link.url}
167+
# except polar_sdk_client.PolarError as e:
168+
# logger.error(f"Error creating Polar.sh checkout session: {e}", exc_info=True)
169+
# raise
170+
171+
logger.info(f"DUMMY: Creating Polar.sh checkout session for item {item_id}, amount {amount} {currency}")
172+
return {
173+
"provider": PROVIDER_POLAR,
174+
"session_id": f"cs_polar_{item_id}_{amount}_{os.urandom(4).hex()}",
175+
"url": f"https://polar.sh/checkout/{item_id}?amount={amount}&currency={currency}&redirect={success_url}", # Placeholder URL
176+
"metadata": metadata
177+
}
178+
179+
def handle_webhook(self, payload: dict, signature: str = None) -> dict:
180+
"""
181+
Handles Polar.sh webhook events.
182+
"""
183+
# In a real scenario, you would verify the signature and process the event:
184+
# try:
185+
# webhook_secret = os.environ.get("POLAR_WEBHOOK_SECRET") # Or from config
186+
# if not webhook_secret:
187+
# raise ValueError("Polar.sh webhook secret not configured.")
188+
#
189+
# # Polar.sh webhook verification might be similar to Stripe or have its own method
190+
# # event = self.polar_client.webhooks.verify_and_parse(payload, signature, webhook_secret)
191+
# # Process event.type (e.g., 'pledge.created', 'pledge.paid', 'issue.funded')
192+
# event_type = payload.get('type', 'unknown')
193+
# event_id = payload.get('id', 'N/A')
194+
# logger.info(f"Polar.sh webhook received: {event_type} (ID: {event_id})")
195+
# return {"status": "success", "provider": PROVIDER_POLAR, "event_id": event_id, "type": event_type}
196+
# except Exception as e: # Catch specific Polar.sh webhook errors (e.g., polar_sdk_client.PolarWebhookError)
197+
# logger.error(f"Error handling Polar.sh webhook: {e}", exc_info=True)
198+
# raise
199+
200+
logger.info(f"DUMMY: Handling Polar.sh webhook. Event type: {payload.get('type', 'unknown')}, ID: {payload.get('id', 'N/A')}")
201+
return {"status": "success", "provider": PROVIDER_POLAR, "event_id": payload.get("id", "unknown"), "type": payload.get("type", "unknown_polar_event")}
202+
203+
# --- Billing Service Orchestrator ---
204+
class BillingService:
205+
"""
206+
Orchestrates various payment providers, offering a unified interface for billing operations.
207+
This service acts as a facade, delegating requests to the appropriate provider.
208+
"""
209+
def __init__(self, api_config: dict):
210+
"""
211+
Initializes the BillingService with API configurations for various providers.
212+
api_config structure:
213+
{
214+
"stripe": {"api_key": "sk_test_...", "webhook_secret": "whsec_..."},
215+
"polar": {"api_key": "ps_test_...", "organization_id": "org_xyz", "webhook_secret": "whsec_..."}
216+
}
217+
"""
218+
self.providers = {}
219+
220+
# Initialize Stripe provider
221+
stripe_config = api_config.get(PROVIDER_STRIPE, {})
222+
stripe_api_key = stripe_config.get("api_key")
223+
if stripe_api_key:
224+
try:
225+
self.providers[PROVIDER_STRIPE] = StripeProvider(api_key=stripe_api_key)
226+
except ValueError as e:
227+
logger.warning(f"Stripe initialization failed: {e}")
228+
else:
229+
logger.warning("Stripe API key not provided. Stripe features may be disabled.")
230+
231+
# Initialize Polar.sh provider
232+
polar_config = api_config.get(PROVIDER_POLAR, {})
233+
polar_api_key = polar_config.get("api_key")
234+
polar_organization_id = polar_config.get("organization_id")
235+
if polar_api_key and polar_organization_id:
236+
try:
237+
self.providers[PROVIDER_POLAR] = PolarProvider(api_key=polar_api_key, organization_id=polar_organization_id)
238+
except ValueError as e:
239+
logger.warning(f"Polar.sh initialization failed: {e}")
240+
else:
241+
logger.warning("Polar.sh API key or organization ID not provided. Polar.sh features may be disabled.")
242+
243+
if not self.providers:
244+
logger.error("No payment providers were successfully initialized. Billing service will be non-functional.")
245+
246+
def _get_provider(self, provider_name: str) -> BasePaymentProvider:
247+
"""
248+
Retrieves the initialized provider instance or raises an error.
249+
"""
250+
provider_instance = self.providers.get(provider_name)
251+
if not provider_instance:
252+
raise ValueError(f"Unsupported or uninitialized payment provider: {provider_name}")
253+
return provider_instance
254+
255+
def create_checkout_session(self, provider: str, item_id: str, amount: int, currency: str, success_url: str, cancel_url: str, metadata: dict = None, **kwargs) -> dict:
256+
"""
257+
Creates a checkout session for the specified payment provider.
258+
Delegates the call to the appropriate provider implementation.
259+
260+
Args:
261+
provider (str): The payment provider ('stripe' or 'polar').
262+
item_id (str): Identifier for the item being purchased/pledged.
263+
amount (int): The amount to charge (in cents for Stripe, or smallest currency unit).
264+
currency (str): The currency code (e.g., 'usd').
265+
success_url (str): URL to redirect to after successful payment.
266+
cancel_url (str): URL to redirect to after cancelled payment.
267+
metadata (dict, optional): Additional metadata to attach to the session.
268+
**kwargs: Provider-specific parameters.
269+
270+
Returns:
271+
dict: A dictionary containing session details, typically including a URL for redirection.
272+
"""
273+
provider_instance = self._get_provider(provider)
274+
logger.info(f"Creating checkout session via {provider} for item {item_id}...")
275+
return provider_instance.create_checkout_session(item_id, amount, currency, success_url, cancel_url, metadata, **kwargs)
276+
277+
def handle_webhook(self, provider: str, payload: dict, signature: str = None) -> dict:
278+
"""
279+
Handles incoming webhooks from payment providers.
280+
Delegates the call to the appropriate provider implementation.
281+
282+
Args:
283+
provider (str): The payment provider ('stripe' or 'polar').
284+
payload (dict): The raw webhook payload.
285+
signature (str, optional): The webhook signature for verification.
286+
287+
Returns:
288+
dict: A dictionary indicating the processing status.
289+
"""
290+
provider_instance = self._get_provider(provider)
291+
logger.info(f"Handling webhook for {provider}. Event type: {payload.get('type', 'unknown')}")
292+
return provider_instance.handle_webhook(payload, signature)

0 commit comments

Comments
 (0)