Skip to content

Commit 522283f

Browse files
committed
Add recurring payments support to StripeProviderV3
- Added recurring_payments and store_payment_method parameters - Implemented autocomplete_with_wallet() for server-initiated charges - Modified create_session() to enable PaymentMethod storage (setup_future_usage) - Added _store_payment_method_from_session() to extract and store PaymentMethod - Enhanced process_data() to handle both Checkout Session and PaymentIntent events - Added _process_payment_intent_webhook() for recurring payment webhooks - Implemented erase_wallet() to detach PaymentMethod from Stripe - Handles 3D Secure via RedirectNeeded exception - Full error handling for card declines and API errors Uses the same wallet interface as PayU - consistent server-initiated recurring payment pattern. Ready for BlenderKit integration.
1 parent 0dc9141 commit 522283f

File tree

1 file changed

+201
-1
lines changed

1 file changed

+201
-1
lines changed

payments/stripe/providers.py

Lines changed: 201 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ class StripeLineItem:
6868
"checkout.session.completed",
6969
]
7070

71+
stripe_payment_intent_events: list = [
72+
"payment_intent.succeeded",
73+
"payment_intent.payment_failed",
74+
"payment_intent.requires_action",
75+
]
76+
7177

7278
class StripeProviderV3(BasicProvider):
7379
"""Provider backend using `Stripe <https://stripe.com/>`_ api version 3.
@@ -76,6 +82,8 @@ class StripeProviderV3(BasicProvider):
7682
:param use_token: Use instance.token instead of instance.pk in client_reference_id
7783
:param endpoint_secret: Endpoint Signing Secret.
7884
:param secure_endpoint: Validate the recieved data, useful for development.
85+
:param recurring_payments: Enable wallet-based recurring payments (server-initiated).
86+
:param store_payment_method: Store PaymentMethod for future use.
7987
"""
8088

8189
form_class = BasePaymentForm
@@ -86,13 +94,17 @@ def __init__(
8694
use_token=True,
8795
endpoint_secret=None,
8896
secure_endpoint=True,
97+
recurring_payments=False,
98+
store_payment_method=False,
8999
**kwargs,
90100
):
91101
super().__init__(**kwargs)
92102
self.api_key = api_key
93103
self.use_token = use_token
94104
self.endpoint_secret = endpoint_secret
95105
self.secure_endpoint = secure_endpoint
106+
self.recurring_payments = recurring_payments
107+
self.store_payment_method = store_payment_method or recurring_payments
96108

97109
def get_form(self, payment, data=None):
98110
if not payment.transaction_id:
@@ -122,6 +134,13 @@ def create_session(self, payment):
122134
"cancel_url": payment.get_failure_url(),
123135
"client_reference_id": payment.token if self.use_token else payment.pk,
124136
}
137+
138+
# Enable payment method storage for recurring payments
139+
if self.store_payment_method:
140+
session_data["payment_intent_data"] = {
141+
"setup_future_usage": "off_session",
142+
}
143+
125144
# Patch session with billing email if exists
126145
if payment.billing_email:
127146
session_data.update({"customer_email": payment.billing_email})
@@ -237,13 +256,103 @@ def get_token_from_request(self, payment, request) -> str:
237256
message="client_reference_id is not present, check Stripe Dashboard.",
238257
) from e
239258

259+
def autocomplete_with_wallet(self, payment):
260+
"""
261+
Complete payment using stored PaymentMethod (server-initiated recurring payment).
262+
263+
This method charges a stored payment method without user interaction.
264+
If 3D Secure or other authentication is required, raises RedirectNeeded.
265+
"""
266+
stripe.api_key = self.api_key
267+
268+
# Get stored PaymentMethod token
269+
payment_method_id = payment.get_renew_token()
270+
if not payment_method_id:
271+
raise PaymentError("No payment method token found for recurring payment")
272+
273+
try:
274+
# Create PaymentIntent with stored PaymentMethod
275+
intent = stripe.PaymentIntent.create(
276+
amount=self.convert_amount(payment.currency, payment.total),
277+
currency=payment.currency.lower(),
278+
payment_method=payment_method_id,
279+
confirm=True, # Immediately attempt to charge
280+
off_session=True, # Server-initiated, user not present
281+
metadata={
282+
"payment_token": payment.token,
283+
"payment_id": payment.pk if not self.use_token else None,
284+
},
285+
)
286+
287+
payment.transaction_id = intent.id
288+
payment.attrs.payment_intent = intent
289+
payment.save()
290+
291+
# Handle immediate response
292+
if intent.status == "succeeded":
293+
payment.captured_amount = payment.total
294+
payment.change_status(PaymentStatus.CONFIRMED)
295+
self._finalize_wallet_payment(payment)
296+
297+
elif intent.status == "requires_action":
298+
# 3D Secure or other authentication needed
299+
if intent.next_action and intent.next_action.type == "redirect_to_url":
300+
redirect_url = intent.next_action.redirect_to_url.url
301+
raise RedirectNeeded(redirect_url)
302+
else:
303+
raise PaymentError(f"Payment requires action: {intent.next_action}")
304+
305+
elif intent.status in ["requires_payment_method", "canceled"]:
306+
# Payment failed
307+
error_message = "Payment failed"
308+
if intent.last_payment_error:
309+
error_message = intent.last_payment_error.message
310+
payment.change_status(PaymentStatus.REJECTED, error_message)
311+
312+
else:
313+
# Other status (processing, requires_capture, etc.)
314+
payment.change_status(PaymentStatus.WAITING)
315+
316+
except stripe.error.CardError as e:
317+
# Card was declined
318+
payment.change_status(PaymentStatus.REJECTED, str(e))
319+
raise PaymentError(f"Card declined: {e}") from e
320+
321+
except stripe.error.StripeError as e:
322+
# Other Stripe error
323+
payment.change_status(PaymentStatus.ERROR, str(e))
324+
raise PaymentError(f"Stripe error: {e}") from e
325+
326+
def erase_wallet(self, wallet):
327+
"""
328+
Detach PaymentMethod from customer (if applicable).
329+
330+
This prevents the payment method from being charged again.
331+
"""
332+
stripe.api_key = self.api_key
333+
334+
if wallet.token:
335+
try:
336+
payment_method = stripe.PaymentMethod.retrieve(wallet.token)
337+
# Only detach if attached to a customer
338+
if hasattr(payment_method, "customer") and payment_method.customer:
339+
payment_method.detach()
340+
except stripe.error.StripeError:
341+
# Payment method doesn't exist or already detached
342+
pass
343+
344+
super().erase_wallet(wallet)
345+
240346
def process_data(self, payment, request):
241347
"""Processes the event sent by stripe.
242348
243349
Updates the payment status and adds the event to the attrs property
244350
"""
245351
event = self.return_event_payload(request)
246-
if event.get("type") in stripe_enabled_events:
352+
event_type = event.get("type")
353+
354+
# Handle Checkout Session events (one-time payments)
355+
if event_type in stripe_enabled_events:
247356
try:
248357
session_info = event["data"]["object"]
249358
except Exception as e:
@@ -259,6 +368,97 @@ def process_data(self, payment, request):
259368
# Paid Order
260369
payment.change_status(PaymentStatus.CONFIRMED)
261370

371+
# Store PaymentMethod for recurring payments
372+
if self.store_payment_method and hasattr(payment, "set_renew_token"):
373+
self._store_payment_method_from_session(payment, session_info)
374+
262375
payment.attrs.session = session_info
263376
payment.save()
377+
378+
# Handle PaymentIntent events (recurring payments)
379+
elif event_type in stripe_payment_intent_events:
380+
return self._process_payment_intent_webhook(payment, event)
381+
382+
return JsonResponse({"status": "OK"})
383+
384+
def _store_payment_method_from_session(self, payment, session_info):
385+
"""
386+
Extract and store PaymentMethod from successful Checkout Session.
387+
"""
388+
stripe.api_key = self.api_key
389+
390+
try:
391+
# Get PaymentIntent from session
392+
payment_intent_id = session_info.get("payment_intent")
393+
if not payment_intent_id:
394+
return
395+
396+
payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
397+
payment_method_id = payment_intent.payment_method
398+
399+
if not payment_method_id:
400+
return
401+
402+
# Get PaymentMethod details
403+
payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
404+
405+
# Extract card details
406+
card_data = {}
407+
if payment_method.type == "card" and payment_method.card:
408+
card_data = {
409+
"card_expire_year": payment_method.card.exp_year,
410+
"card_expire_month": payment_method.card.exp_month,
411+
"card_masked_number": payment_method.card.last4,
412+
}
413+
414+
# Store token
415+
payment.set_renew_token(
416+
token=payment_method_id,
417+
automatic_renewal=True,
418+
**card_data,
419+
)
420+
421+
except stripe.error.StripeError:
422+
# Failed to retrieve payment method, but payment was successful
423+
# Don't fail the payment, just skip storing the method
424+
pass
425+
426+
def _process_payment_intent_webhook(self, payment, event):
427+
"""
428+
Handle PaymentIntent webhooks for recurring payments.
429+
"""
430+
try:
431+
intent = event["data"]["object"]
432+
except Exception as e:
433+
raise PaymentError(
434+
code=400, message="payment_intent not present in webhook"
435+
) from e
436+
437+
# Verify this is our payment
438+
if payment.transaction_id != intent.id:
439+
return JsonResponse({"status": "OK", "message": "Payment ID mismatch"})
440+
441+
event_type = event.get("type")
442+
443+
if event_type == "payment_intent.succeeded":
444+
payment.captured_amount = payment.total
445+
payment.change_status(PaymentStatus.CONFIRMED)
446+
payment.attrs.payment_intent = intent
447+
payment.save()
448+
self._finalize_wallet_payment(payment)
449+
450+
elif event_type == "payment_intent.payment_failed":
451+
error_message = "Payment failed"
452+
if intent.get("last_payment_error"):
453+
error_message = intent["last_payment_error"].get("message", error_message)
454+
payment.change_status(PaymentStatus.REJECTED, error_message)
455+
payment.attrs.payment_intent = intent
456+
payment.save()
457+
458+
elif event_type == "payment_intent.requires_action":
459+
# Payment requires user action (3DS)
460+
payment.change_status(PaymentStatus.INPUT)
461+
payment.attrs.payment_intent = intent
462+
payment.save()
463+
264464
return JsonResponse({"status": "OK"})

0 commit comments

Comments
 (0)