@@ -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
7278class 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