def curlPaypalIPN(url, headers, post_data=''): _debug('curlPaypalIPN url: {}'.format(url)) buffer = BytesIO() c = pycurl.Curl() c.setopt(c.URL, url) c.setopt(c.HTTP_VERSION, pycurl.CURL_HTTP_VERSION_1_1) c.setopt(c.WRITEDATA, buffer) c.setopt(c.CAINFO, certifi.where()) c.setopt(c.HTTPHEADER, headers) c.setopt(c.FORBID_REUSE, 1) c.setopt(c.POST, 1) if post_data: c.setopt(c.POSTFIELDS, post_data) c.setopt(c.VERBOSE, True) c.perform() status_code = c.getinfo(pycurl.HTTP_CODE) c.close() body = buffer.getvalue() # Body is a byte string. # We have to know the encoding in order to print it to a text file # such as standard output. if status_code >= 300: raise RuntimeError("curlPaypalipN request unsuccessful. Status Code: {}, Full body: {}".format(status_code, body.decode('utf-8'))) # print("Curl to PayPal status code: {}({})".format(status_code, type(status_code))) # Here we deserialize the json into a python object # _debug('Success! curlPaypalIPN body: {}'.format(body.decode('utf-8'))) return body.decode('utf-8')
def curlPaypal(url, headers, userpwd='', post_data='', verb='GET'): _debug('curlPaypal url: {}'.format(url)) buffer = BytesIO() c = pycurl.Curl() c.setopt(c.URL, url) c.setopt(c.WRITEDATA, buffer) c.setopt(c.CAINFO, certifi.where()) c.setopt(c.HTTPHEADER, headers) if userpwd: c.setopt(c.USERPWD, userpwd) if post_data: c.setopt(c.POSTFIELDS, post_data) if verb == 'POST': c.setopt(c.CUSTOMREQUEST, 'POST') elif verb == 'PATCH': c.setopt(c.CUSTOMREQUEST, 'PATCH') c.setopt(c.VERBOSE, True) c.perform() status_code = c.getinfo(pycurl.HTTP_CODE) c.close() body = buffer.getvalue() # Body is a byte string. # We have to know the encoding in order to print it to a text file # such as standard output. if status_code == 204: _debug('curlPaypal returns 204') return {} if status_code >= 300: raise RuntimeError( "curlPaypal request unsuccessful. Status Code: {}, Full body: {}". format(status_code, body.decode('utf-8'))) # print("Curl to PayPal status code: {}({})".format(status_code, type(status_code))) # Here we deserialize the json into a python object return json.loads(body.decode('utf-8'))
def verify_paypal_response(request): """ This endpoint should be set as the listening endpoint for the webhook set in PayPal's developer dashboard(within a REST API App) The verification of the incoming PayPal requests is done via Factory_Paypal.initGatewayByVerification(request) The webhook should be set with the following events only: Billing subscription activated Billing subscription cancelled Billing subscription updated Payment capture completed Payment sale completed For more info on how to build this webhook endpoint, refer to PayPal documentation: https://developer.paypal.com/docs/subscriptions/integrate/ @todo: revise error handling, avoid catching all exceptions at the end """ try: # Set up gateway manager object with its linking donation, session, etc... gatewayManager = Factory_Paypal.initGatewayByVerification(request) if gatewayManager: return gatewayManager.process_webhook_response() else: raise Exception(_('gatewayManager for paypal not initialized.')) except WebhookNotProcessedError as error: # beware: this exception should be reserved for the incoming but not processed webhook events _debug(str(error)) # return 200 to prevent resending of paypal server of those requests return HttpResponse(status=200) except ValueError as error: _exception(str(error)) return HttpResponse(status=500) except Exception as error: _exception(str(error)) return HttpResponse(status=500)
def checkAccessTokenExpiry(session): paypalSettings = getPayPalSettings() token_uri = '/v1/oauth2/token' # check if the paypal_token_expiry stored in session has passed or not current_time = round(time.time()) if 'paypal_token_expiry' not in session or current_time > session[ 'paypal_token_expiry']: saveNewAccessToken(session, paypalSettings.api_url + token_uri, paypalSettings.client_id, paypalSettings.secret_key) _debug("Paypal Auth Token: " + session['paypal_token'])
def isUpdateSubsFrequencyLimitationPassed(gatewayManager): site_settings = get_site_settings_from_default_site() if site_settings.limit_fiveactions_per_fivemins: # get count of the actions carried out by the same donor in the last 5 minutes nowdt = datetime.now(dt_timezone.utc) fiveminsbf = nowdt - timedelta(minutes=5) count = UserSubscriptionUpdatesLog.objects.filter( user=gatewayManager.subscription.user, created_at__gte=fiveminsbf).count() _debug( 'Count of Subscription Actions done by {} within five minutes: {}'. format(gatewayManager.subscription.user.fullname, count)) if count >= 5: return False return True
def verify_stripe_response(request): """ This endpoint should be set as the listening endpoint for the webhook set in Stripe's dashboard The verification of the incoming Stripe requests is done via Factory_Stripe.initGatewayByVerification(request) The webhook should be set with the following events only: payment_intent.succeeded customer.subscription.deleted customer.subscription.updated invoice.paid invoice.created checkout.session.completed For more info on how to build this webhook endpoint, refer to Stripe documentation: https://stripe.com/docs/webhooks @todo: revise error handling, avoid catching all exceptions at the end """ try: # Set up gateway manager object with its linking donation, session, etc... gatewayManager = Factory_Stripe.initGatewayByVerification(request) return gatewayManager.process_webhook_response() except WebhookNotProcessedError as error: # beware: this exception should be reserved for the incoming but not processed webhook events, or events processed but data not needed further action _debug(str(error)) # return 200 for attaining a higher rate of successful response rate at Stripe backend return HttpResponse(status=200) except ValueError as e: # Might be invalid payload from initGatewayByVerification # or missing donation_id/subscription_id or donation object not found _exception(str(e)) return HttpResponse(status=400) except stripe.error.SignatureVerificationError as e: # Invalid signature from initGatewayByVerification _exception(str(e)) return HttpResponse(status=400) except (stripe.error.RateLimitError, stripe.error.InvalidRequestError, stripe.error.AuthenticationError, stripe.error.APIConnectionError, stripe.error.StripeError) as e: _exception( "Stripe API Error({}): Status({}), Code({}), Param({}), Message({})" .format( type(e).__name__, e.http_status, e.code, e.param, e.user_message)) return HttpResponse(status=int(e.http_status)) except Exception as e: _exception(str(e)) return HttpResponse(status=500)
def return_from_paypal(request): """ This endpoint is submitted as the return_url when creating the PayPal Subscription/Order at create_paypal_transaction(request) This url should receive GET params: 'token' and 'subscription_id'(only recurring payments); 'ba_token' is not used In Factory_PayPal.initGatewayByReturn(request), we save the subscription_id/token into the donationPaymentMeta data upon a successful request; exception will be raised if the endpoint is reached but a previous meta value is found, this is done to prevent this endpoint being called unlimitedly @todo: revise error handling, avoid catching all exceptions at the end """ try: gatewayManager = Factory_Paypal.initGatewayByReturn(request) request.session['return-donation-id'] = gatewayManager.donation.id # subscription donation updates are handled by webhooks # returning from paypal only needs to deal with onetime donations if not gatewayManager.donation.is_recurring: # further capture payment if detected order approved, if not just set payment as processing and leave it to webhook processing if gatewayManager.order_status == 'APPROVED': # might raise IOError/HttpError capture_response = capture_paypal_order(gatewayManager.donation, gatewayManager.order_id) if capture_response.status == 'COMPLETED': _debug('PayPal: Order Captured. Payment Completed.') gatewayManager.donation.payment_status = STATUS_COMPLETE gatewayManager.donation.donation_date = datetime.now(timezone.utc) gatewayManager.donation.transaction_id = capture_response.purchase_units[0].payments.captures[0].id gatewayManager.donation.save() else: _debug('PayPal: Order status after Paypal returns: '+gatewayManager.order_status) else: # save the subscription_id as profile_id gatewayManager.donation.subscription.profile_id = request.GET.get('subscription_id') gatewayManager.donation.subscription.save() except IOError as error: request.session['error-title'] = str(_("IOError")) request.session['error-message'] = str(error) _exception(str(error)) except ValueError as error: request.session['error-title'] = str(_("ValueError")) request.session['error-message'] = str(error) _exception(str(error)) except Exception as error: request.session['error-title'] = str(_("Exception")) request.session['error-message'] = str(error) _exception(str(error)) return redirect('donations:thank-you')
def capture_paypal_order(donation, order_id): """Method to capture order using order_id""" paypalSettings = getPayPalSettings() client = PayPalHttpClient(paypalSettings.environment) req = OrdersCaptureRequest(order_id) _debug('PayPal: Capture Order') try: response = client.execute(req) except IOError as ioe: fail_reason = str(ioe) if isinstance(ioe, HttpError): # Something went wrong server-side httpError = json.loads(ioe.message) if 'details' in httpError and len(httpError['details']) > 0: fail_reason = process_capture_failure( donation, httpError['details'][0]['issue'], httpError['details'][0]['description']) # update donation status to failed donation.payment_status = STATUS_FAILED donation.save() raise IOError(fail_reason) return response.result
def initGatewayByReturn(request): # a get param named 'token' contains the order_id paypalSettings = getPayPalSettings() client = PayPalHttpClient(paypalSettings.environment) donation_id = None subscription_obj = {} kwargs = {} if request.GET.get('subscription_id', None): # recurring payment subscription_obj = getSubscriptionDetails( request.session, request.GET.get('subscription_id')) kwargs['subscription_obj'] = subscription_obj if 'custom_id' in subscription_obj: donation_id = subscription_obj['custom_id'] else: raise ValueError( _('Missing custom_id(donation_id) in curlPaypal-returned subscription' )) elif request.GET.get('token', None): # onetime payment req = OrdersGetRequest(request.GET.get('token')) # might throw IOError response = client.execute(req) _debug('PayPal: Returns from Gateway') _debug('PayPal: Order status: ' + response.result.status) donation_id = response.result.purchase_units[0].custom_id kwargs['order_id'] = request.GET.get('token') kwargs['order_status'] = response.result.status if not donation_id: raise ValueError( _("Missing donation_id in purchase_units custom_id attribute" )) else: raise ValueError(_("Missing token from PayPal request")) try: donation = Donation.objects.get(pk=donation_id) if request.GET.get('subscription_id', None): # raise error if paypal_subscription_id already found in DonationPaymentMeta dpm = DonationPaymentMeta.objects.filter( donation=donation, field_key='paypal_subscription_id', field_value=request.GET.get('subscription_id')) if len(dpm) >= 1: raise ValueError( _("PayPal subscription id found. Return request from PayPal is already invalid." )) else: dpm = DonationPaymentMeta( donation=donation, field_key='paypal_subscription_id', field_value=request.GET.get('subscription_id')) dpm.save() elif request.GET.get('token', None): # raise error if paypal_token already found in DonationPaymentMeta dpm = DonationPaymentMeta.objects.filter( donation=donation, field_key='paypal_token', field_value=request.GET.get('token')) if len(dpm) >= 1: raise ValueError( _("PayPal token found. Return request from PayPal is already invalid." )) else: dpm = DonationPaymentMeta( donation=donation, field_key='paypal_token', field_value=request.GET.get('token')) dpm.save() return Factory_Paypal.initGateway(request, donation, None, **kwargs) except Donation.DoesNotExist: raise ValueError( _("Donation object not found by id: ") + str(donation_id))
def process_webhook_response(self): initStripeApiKey() # Decide what actions to perform on Newstream's side according to the results/events from the Stripe notifications # Event: checkout.session.completed if self.event['type'] == EVENT_CHECKOUT_SESSION_COMPLETED: # Update payment status self.donation.payment_status = STATUS_COMPLETE # update donation_date self.donation.donation_date = datetime.now(timezone.utc) self.donation.save() # Since for recurring payment, subscription.updated event might lag behind checkout.session.completed if not self.donation.is_recurring: sendDonationReceiptToDonor(self.donation) sendDonationNotifToAdmins(self.donation) return HttpResponse(status=200) # Event: payment_intent.succeeded # Should be handled for onetime donations if self.event['type'] == EVENT_PAYMENT_INTENT_SUCCEEDED: # Update payment transaction_id as the charge id self.donation.transaction_id = self.payment_intent['charges'][ 'data'][0]['id'] self.donation.save() return HttpResponse(status=200) # Event: invoice.created (for subscriptions, just return 200 here and do nothing - to signify to Stripe that it can proceed and finalize the invoice) # https://stripe.com/docs/billing/subscriptions/webhooks#understand if self.event['type'] == EVENT_INVOICE_CREATED and hasattr( self, 'subscription_obj') and hasattr(self, 'invoice'): return HttpResponse(status=200) # Event: invoice.paid (for subscriptions) if self.event['type'] == EVENT_INVOICE_PAID and hasattr( self, 'subscription_obj') and hasattr(self, 'invoice'): if self.invoice.status == 'paid': _debug("[stripe recurring] Invoice confirmed paid") # check if subscription has one or more invoices to determine it's a first time or renewal payment # self.subscription_obj here is the stripe subscription object try: invoices = stripe.Invoice.list( subscription=self.subscription_obj.id) except (stripe.error.RateLimitError, stripe.error.InvalidRequestError, stripe.error.AuthenticationError, stripe.error.APIConnectionError, stripe.error.StripeError) as e: raise RuntimeError( "Stripe API Error({}): Status({}), Code({}), Param({}), Message({})" .format( type(e).__name__, e.http_status, e.code, e.param, e.user_message)) # _debug("Stripe: Subscription {} has {} invoices.".format(self.subscription_obj.id, len(invoices['data']))) if len(invoices['data']) == 1: _debug("[stripe recurring] First time subscription") # save charge id as donation.transaction_id self.donation.transaction_id = self.invoice.charge self.donation.payment_status = STATUS_COMPLETE self.donation.save() # also save the invoice number as a DonationPaymentMeta dpmeta = DonationPaymentMeta( donation=self.donation, field_key='stripe_invoice_number', field_value=self.invoice.number) dpmeta.save() elif len(invoices['data']) > 1: _debug("[stripe recurring] About to add renewal donation") # create a new donation record + then send donation receipt to user # self.donation is the first donation made for a subscription donation = Donation( is_test=self.donation.is_test, subscription=self.donation.subscription, transaction_id=self.invoice.charge, user=self.donation.user, form=self.donation.form, gateway=self.donation.gateway, is_recurring=True, donation_amount=formatDonationAmountFromGateway( str(self.invoice.amount_paid), self.donation.currency), currency=self.donation.currency, payment_status=STATUS_COMPLETE, donation_date=datetime.now(timezone.utc), ) donation.save() dpmeta = DonationPaymentMeta( donation=donation, field_key='stripe_invoice_number', field_value=self.invoice.number) dpmeta.save() # email notifications sendRenewalReceiptToDonor(donation) sendRenewalNotifToAdmins(donation) # log down the current subscription period span spmeta = SubscriptionPaymentMeta( subscription=self.donation.subscription, field_key='stripe_subscription_period', field_value=str(self.subscription_obj.current_period_start) + '-' + str(self.subscription_obj.current_period_end)) spmeta.save() return HttpResponse(status=200) # Event: customer.subscription.updated if self.event[ 'type'] == EVENT_CUSTOMER_SUBSCRIPTION_UPDATED and hasattr( self, 'subscription_obj'): # Subscription active after invoice paid if self.subscription_obj['status'] == 'active': if self.donation.subscription.recurring_status == STATUS_PROCESSING: # save the new subscription, marked by profile_id self.donation.subscription.profile_id = self.subscription_obj.id self.donation.subscription.recurring_amount = formatDonationAmountFromGateway( self.subscription_obj['items']['data'][0]['price'] ['unit_amount_decimal'], self.donation.currency) self.donation.subscription.currency = self.donation.currency self.donation.subscription.recurring_status = STATUS_ACTIVE self.donation.subscription.save() # set donation payment_status to complete(as this event may be faster than checkout.session.completed, for the email is sent next line) self.donation.payment_status = STATUS_COMPLETE self.donation.save() # send the new recurring notifs to admins and donor as subscription is just active sendNewRecurringNotifToAdmins(self.donation.subscription) sendNewRecurringNotifToDonor(self.donation.subscription) else: # check if pause_collection is marked_uncollectible if self.subscription_obj[ 'pause_collection'] and self.subscription_obj[ 'pause_collection'][ 'behavior'] == 'mark_uncollectible': self.donation.subscription.recurring_status = STATUS_PAUSED else: self.donation.subscription.recurring_status = STATUS_ACTIVE self.donation.subscription.save() # price changes events should goes through the if-else block and returns 200 right here return HttpResponse(status=200) else: return HttpResponse(status=400) # Event: customer.subscription.deleted # self.donation is not initialized here, reason refer to Factory_Stripe.initGatewayByVerification if self.event[ 'type'] == EVENT_CUSTOMER_SUBSCRIPTION_DELETED and hasattr( self, 'subscription_obj'): # update subscription recurring_status self.donation.subscription.recurring_status = STATUS_CANCELLED self.donation.subscription.save() # email notifications here because cancellation might occur manually at the stripe dashboard sendRecurringCancelledNotifToAdmins(self.donation.subscription) sendRecurringCancelledNotifToDonor(self.donation.subscription) return HttpResponse(status=200) # for other events: return HttpResponse(status=400)
def process_webhook_response(self): # case one: donation is passed + not first_time_subscription = onetime donation if self.donation and not hasattr(self, 'first_time_subscription'): # change donation payment_status to 2c2p's payment_status, update recurring_status self.donation.payment_status = map2C2PPaymentStatus( self.data['payment_status']) self.donation.donation_date = datetime.now() self.donation.save() # email notifications if self.donation.payment_status == STATUS_REVOKED: sendDonationRevokedToDonor(self.donation) sendDonationRevokedToAdmins(self.donation) else: sendDonationReceiptToDonor(self.donation) sendDonationNotifToAdmins(self.donation) return HttpResponse(status=200) # case two: donation is passed + first_time_subscription: true if self.donation and self.first_time_subscription: # change donation payment_status to 2c2p's payment_status, update recurring_status self.donation.payment_status = map2C2PPaymentStatus( self.data['payment_status']) self.donation.save() if self.donation.payment_status == STATUS_COMPLETE: # create new Subscription object subscription = Subscription( is_test=self.testing_mode, profile_id=self.data['recurring_unique_id'], user=self.donation.user, gateway=self.donation.gateway, recurring_amount=extract_payment_amount( self.data['amount'], self.data['currency']), currency=currencyCodeToKey(self.data['currency']), recurring_status=STATUS_ACTIVE, subscribe_date=datetime.now(timezone.utc)) subscription.save() # link subscription to the donation self.donation.subscription = subscription self.donation.save() # send the donation receipt to donor and notification to admins if subscription is just created sendDonationReceiptToDonor(self.donation) sendDonationNotifToAdmins(self.donation) return HttpResponse(200) else: raise ValueError( _("Cannot create subscription object due to donation payment_status: %(status)s" ) % {'status': self.donation.payment_status}) # case 3: renewals if not self.donation and self.subscription: # find the first donation made for this subscription fDonation = Donation.objects.filter( subscription=self.subscription).order_by('id').first() # Create new donation record from fDonation donation = Donation( is_test=self.testing_mode, subscription=self.subscription, transaction_id=self.data['order_id'], user=fDonation.user, form=fDonation.form, gateway=fDonation.gateway, is_recurring=True, donation_amount=extract_payment_amount(self.data['amount'], self.data['currency']), currency=currencyCodeToKey(self.data['currency']), payment_status=map2C2PPaymentStatus( self.data['payment_status']), donation_date=datetime.now(timezone.utc), ) _debug('Save renewal Donation:' + self.data['order_id']) donation.save() # email notifications if donation.payment_status == STATUS_REVOKED: sendDonationRevokedToDonor(donation) sendDonationRevokedToAdmins(donation) else: sendRenewalReceiptToDonor(donation) sendRenewalNotifToAdmins(donation) return HttpResponse(200) else: raise RuntimeError( _("Unable to process_webhook_response after verifying 2C2P request" ))
def create_paypal_transaction(request): """ When the user reaches last step after confirming the donation, user is redirected via gatewayManager.redirect_to_gateway_url(), which renders redirection_paypal.html This function calls to PayPal Api to create a PayPal subscription object or PayPal order(one-time donation), then this function returns the approval_link to frontend js and to redirect to PayPal's checkout page Sample (JSON) request: { 'csrfmiddlewaretoken': 'LZSpOsb364pn9R3gEPXdw2nN3dBEi7RWtMCBeaCse2QawCFIndu93fD3yv9wy0ij' } @todo: revise error handling, avoid catching all exceptions at the end """ errorObj = { "issue": "Exception", "description": "" } result = {} try: paypalSettings = getPayPalSettings() donation_id = request.session.pop('donation_id', None) if not donation_id: raise ValueError(_("Missing donation_id in session")) donation = Donation.objects.get(pk=int(donation_id)) if donation.is_recurring: # Product should have been created by admin manually at the dashboard/setup wizard # if no product exists, create one here(double safety net) # todo: make sure the product_id in site_settings has been set by some kind of configuration enforcement before site is launched product_list = listProducts(request.session) product = None if len(product_list['products']) == 0: product = createProduct(request.session) else: # get the product, should aim at the product with the specific product id for prod in product_list['products']: if prod['id'] == paypalSettings.product_id: product = prod if product == None: raise ValueError(_('Cannot initialize/get the paypal product object')) # Create plan and subscription plan = createPlan(request.session, product['id'], donation) if plan['status'] == 'ACTIVE': subscription = createSubscription(request.session, plan['id'], donation) result['subscription_id'] = subscription['id'] for link in subscription['links']: if link['rel'] == 'approve': result['approval_link'] = link['href'] else: raise ValueError(_("Newly created PayPal plan is not active, status: %(status)s") % {'status': plan['status']}) # else: one-time donation else: response = create_paypal_order(request.session, donation) ppresult = response.result _debug('PayPal: Order Created Status: '+ppresult.status) # set approval_link attribute for link in ppresult.links: _debug('PayPal: --- {}: {} ---'.format(link.rel, link.href)) if link.rel == 'approve': result['approval_link'] = link.href except ValueError as e: _exception(str(e)) errorObj['issue'] = "ValueError" errorObj['description'] = str(e) return JsonResponse(object_to_json(errorObj), status=500) except Donation.DoesNotExist: _exception("Donation.DoesNotExist") errorObj['issue'] = "Donation.DoesNotExist" errorObj['description'] = str(_("Donation object not found by id: %(id)s") % {'id': donation_id}) return JsonResponse(object_to_json(errorObj), status=500) except HttpError as ioe: # Catching exceptions from the paypalclient execution, HttpError is a subclass of IOError httpError = json.loads(ioe.message) if 'details' in httpError and len(httpError['details']) > 0: errorObj["issue"] = httpError['details'][0]['issue'] errorObj["description"] = httpError['details'][0]['description'] _exception(errorObj["description"]) # update donation status to failed donation.payment_status = STATUS_FAILED donation.save() return JsonResponse(object_to_json(errorObj), status=ioe.status_code) except Exception as error: errorObj['description'] = str(error) _exception(errorObj["description"]) return JsonResponse(object_to_json(errorObj), status=500) return JsonResponse(object_to_json(result))
def initGatewayByVerification(request): paypalSettings = getPayPalSettings() # The payload body sent in the webhook event event_body = request.body.decode() json_data = json.loads(request.body) _debug('Event Type: ' + json_data['event_type']) # printvars(request.headers) # Paypal-Transmission-Id in webhook payload header transmission_id = request.headers['Paypal-Transmission-Id'] # Paypal-Transmission-Time in webhook payload header timestamp = request.headers['Paypal-Transmission-Time'] # Webhook id created webhook_id = paypalSettings.webhook_id # Paypal-Transmission-Sig in webhook payload header actual_signature = request.headers['Paypal-Transmission-Sig'] # Paypal-Cert-Url in webhook payload header cert_url = request.headers['Paypal-Cert-Url'] # PayPal-Auth-Algo in webhook payload header auth_algo = request.headers['PayPal-Auth-Algo'] response = WebhookEvent.verify(transmission_id, timestamp, webhook_id, event_body, cert_url, actual_signature, auth_algo) if not response: _debug('Webhook verification result: ' + str(response)) if response: donation_id = None subscription = None subscription_obj = None kwargs = {} expected_events = [ EVENT_PAYMENT_CAPTURE_COMPLETED, EVENT_BILLING_SUBSCRIPTION_ACTIVATED, EVENT_BILLING_SUBSCRIPTION_UPDATED, EVENT_PAYMENT_SALE_COMPLETED, EVENT_BILLING_SUBSCRIPTION_CANCELLED ] # one-time donation payment captured if json_data['event_type'] == EVENT_PAYMENT_CAPTURE_COMPLETED: if 'custom_id' in json_data['resource']: donation_id = json_data['resource']['custom_id'] else: raise ValueError( _('Missing custom_id(donation_id) in json_data.resource' )) # subscription activated if json_data['event_type'] == EVENT_BILLING_SUBSCRIPTION_ACTIVATED: subscription_obj = json_data['resource'] if 'custom_id' in json_data['resource']: donation_id = json_data['resource']['custom_id'] else: raise ValueError( _('Missing custom_id(donation_id) in json_data.resource' )) # subscription updated if json_data['event_type'] == EVENT_BILLING_SUBSCRIPTION_UPDATED: subscription_obj = json_data['resource'] if 'custom_id' in json_data['resource']: donation_id = json_data['resource']['custom_id'] else: raise ValueError( _('Missing custom_id(donation_id) in json_data.resource' )) # subscription payment sale completed if json_data['event_type'] == EVENT_PAYMENT_SALE_COMPLETED: subscription_id = json_data['resource']['billing_agreement_id'] subscription_obj = getSubscriptionDetails( request.session, subscription_id) if 'custom_id' in subscription_obj: donation_id = subscription_obj['custom_id'] else: raise ValueError( _('Missing custom_id(donation_id) in curlPaypal-returned subscription_obj' )) # subscription cancelled if json_data['event_type'] == EVENT_BILLING_SUBSCRIPTION_CANCELLED: subscription_obj = json_data['resource'] if 'custom_id' in json_data['resource']: donation_id = json_data['resource']['custom_id'] else: raise ValueError( _('Missing custom_id(donation_id) in json_data.resource' )) if json_data['event_type'] in expected_events and not donation_id: raise ValueError( _("Missing donation_id after processing events from paypal" )) if json_data['event_type'] not in expected_events: raise WebhookNotProcessedError( _("PayPal Event not expected for processing at the moment") ) try: donation = Donation.objects.get(pk=donation_id) kwargs['payload'] = json_data['resource'] kwargs['event_type'] = json_data['event_type'] kwargs['subscription_obj'] = subscription_obj return Factory_Paypal.initGateway(request, donation, subscription, **kwargs) except Donation.DoesNotExist: raise ValueError( _("Donation object not found by id: ") + str(donation_id)) else: raise ValueError(_("PayPal Webhook verification failed."))
def initGatewayByVerification(request): ''' This method verifies webhooks of either Newstream-created transactions or Givewp-created subscriptions In givewp, $event_json->data->object->charge is ensured to be not empty before processing the webhooks, but I cannot find the 'charge' attribute in my Newstream 'customer.subscription.deleted' event, thus I will not apply the same checking here but run the same stripe_signature header check instead. ''' initStripeApiKey() stripeSettings = getStripeSettings() payload = request.body sig_header = request.META['HTTP_STRIPE_SIGNATURE'] donation_id = None can_skip_donation_id = False event = None session = None payment_intent = None donation = None subscription = None subscription_obj = None invoice = None kwargs = {} expected_events = [ EVENT_CHECKOUT_SESSION_COMPLETED, EVENT_PAYMENT_INTENT_SUCCEEDED, EVENT_INVOICE_CREATED, EVENT_INVOICE_PAID, EVENT_CUSTOMER_SUBSCRIPTION_UPDATED, EVENT_CUSTOMER_SUBSCRIPTION_DELETED ] # the following call will raise ValueError/stripe.error.SignatureVerificationError event = stripe.Webhook.construct_event(payload, sig_header, stripeSettings.webhook_secret) # for events not being processed if event['type'] not in expected_events: raise WebhookNotProcessedError( _("Stripe Event not expected for processing at the moment")) _debug("[stripe recurring] Incoming Event type:" + event['type']) # Intercept the checkout.session.completed event if event['type'] == EVENT_CHECKOUT_SESSION_COMPLETED: session = event['data']['object'] # Fulfill the purchase... if session: if session.mode == 'payment' and session.payment_intent: payment_intent = stripe.PaymentIntent.retrieve( session.payment_intent) if 'donation_id' in payment_intent.metadata: donation_id = payment_intent.metadata['donation_id'] else: raise ValueError( _('Missing donation_id in payment_intent.metadata') ) elif session.mode == 'subscription' and session.subscription: subscription_obj = stripe.Subscription.retrieve( session.subscription) if 'donation_id' in subscription_obj.metadata: donation_id = subscription_obj.metadata['donation_id'] else: raise ValueError( _('Missing donation_id in subscription_obj.metadata' )) # Intercept the payment_intent.succeeded event if event['type'] == EVENT_PAYMENT_INTENT_SUCCEEDED: payment_intent = event['data']['object'] # Fulfill the purchase... if payment_intent: try: dpm = DonationPaymentMeta.objects.get( field_key='stripe_payment_intent_id', field_value=payment_intent.id) donation = dpm.donation can_skip_donation_id = True except DonationPaymentMeta.DoesNotExist: # should be renewal payments since only one-time payments have saved stripe_payment_intent_id raise WebhookNotProcessedError( _('Payment Intent Id not found in DonationPaymentMeta:' ) + payment_intent.id) # Intercept the invoice created event for subscriptions(a must for instant invoice finalization) # if it's givewp subscription -> no donation_id in metadata is set -> query subscription from profile_id -> then get first donation as parent donation # if it's newstream-created subscription -> has donation_id in metadata -> query donation is enough(since profile_id might not have yet been set in subscription) # https://stripe.com/docs/billing/subscriptions/webhooks#understand if event['type'] == EVENT_INVOICE_CREATED: invoice = event['data']['object'] subscription_id = invoice.subscription if subscription_id: subscription_obj = stripe.Subscription.retrieve( subscription_id) if 'donation_id' in subscription_obj.metadata: # newstream-created subscription donation_id = subscription_obj.metadata['donation_id'] else: # givewp subscription try: subscription = Subscription.objects.get( profile_id=subscription_id) donation = Donation.objects.filter( subscription=subscription).order_by('id').first() can_skip_donation_id = True if not donation: raise ValueError( _('Missing parent donation queried via Subscription, subscription_id: ' ) + subscription_id) except Subscription.DoesNotExist: raise ValueError( _('No matching Subscription found, profile_id: ') + subscription_id) else: raise ValueError(_('Missing subscription_id')) # Intercept the invoice paid event for subscriptions # At Givewp, invoice.payment_succeeded is processed instead # but I checked that the payloads of both invoice.paid and invoice.payment_succeeded are the same # so I will process only the invoice.paid event for both Newstream or Givewp Subscriptions # if it's givewp subscription -> no donation_id in metadata is set -> query subscription from profile_id -> then get first donation as parent donation # if it's newstream-created subscription -> has donation_id in metadata -> query donation is enough(since profile_id might not have yet been set in subscription) if event['type'] == EVENT_INVOICE_PAID: invoice = event['data']['object'] subscription_id = invoice.subscription if subscription_id: subscription_obj = stripe.Subscription.retrieve( subscription_id) if 'donation_id' in subscription_obj.metadata: # newstream-created subscription donation_id = subscription_obj.metadata['donation_id'] else: # givewp subscription try: subscription = Subscription.objects.get( profile_id=subscription_id) donation = Donation.objects.filter( subscription=subscription).order_by('id').first() can_skip_donation_id = True if not donation: raise ValueError( _('Missing parent donation queried via Subscription, subscription_id: ' ) + subscription_id) except Subscription.DoesNotExist: raise ValueError( _('No matching Subscription found, profile_id: ') + subscription_id) else: raise ValueError(_('Missing subscription_id')) # The subscription created event is not to be used for subscription model init, so as to prevent race condition with the subscription model init in updated event # That is because there is no guarantee which event hits first, it's better to let one event handles the model init as well. # Intercept the subscription updated event # if it's givewp subscription -> no donation_id in metadata is set -> query subscription from profile_id -> then get first donation as parent donation # if it's newstream-created subscription -> has donation_id in metadata -> query donation is enough(since profile_id might not have yet been set in subscription) if event['type'] == EVENT_CUSTOMER_SUBSCRIPTION_UPDATED: subscription_obj = event['data']['object'] if subscription_obj: if 'donation_id' in subscription_obj.metadata: # newstream-created subscription donation_id = subscription_obj.metadata['donation_id'] else: # givewp subscription subscription_id = subscription_obj.id try: subscription = Subscription.objects.get( profile_id=subscription_id) donation = Donation.objects.filter( subscription=subscription).order_by('id').first() can_skip_donation_id = True if not donation: raise ValueError( _('Missing parent donation queried via Subscription, subscription_id: ' ) + subscription_id) except Subscription.DoesNotExist: raise ValueError( _('No matching Subscription found, profile_id: ') + subscription_id) # Intercept the subscription deleted event # This event links to either Newstream or Givewp created subscriptions # if it's givewp subscription -> no donation_id in metadata is set -> query subscription from profile_id -> then get first donation as parent donation # if it's newstream-created subscription -> has donation_id in metadata -> query donation is enough(since profile_id might not have yet been set in subscription) if event['type'] == EVENT_CUSTOMER_SUBSCRIPTION_DELETED: subscription_obj = event['data']['object'] if subscription_obj: if 'donation_id' in subscription_obj.metadata: # newstream-created subscription donation_id = subscription_obj.metadata['donation_id'] else: # givewp subscription subscription_id = subscription_obj.id try: subscription = Subscription.objects.get( profile_id=subscription_id) donation = Donation.objects.filter( subscription=subscription).order_by('id').first() can_skip_donation_id = True if not donation: raise ValueError( _('Missing parent donation queried via Subscription, subscription_id: ' ) + subscription_id) except Subscription.DoesNotExist: raise ValueError( _('No matching Subscription found, profile_id: ') + subscription_id) # Finally init and return the Stripe Gateway Manager if not donation_id and not can_skip_donation_id: raise ValueError(_('Missing donation_id')) try: # no need to query donation object if can skip if not can_skip_donation_id and not donation: donation = Donation.objects.get(pk=donation_id) kwargs['session'] = session kwargs['event'] = event kwargs['payment_intent'] = payment_intent kwargs['subscription_obj'] = subscription_obj kwargs['invoice'] = invoice return Factory_Stripe.initGateway(request, donation, subscription, **kwargs) except Donation.DoesNotExist: raise ValueError( _('No matching Donation found, donation_id: ') + str(donation_id))
def initGatewayByVerification(request): settings = get2C2PSettings() data = {} # debugging POST params from 2C2P for key, value in request.POST.items(): _debug(key + ': ' + value) for key in getResponseParamOrder(): if key in request.POST: data[key] = request.POST[key] if 'hash_value' in request.POST and request.POST['hash_value']: hash_value = request.POST['hash_value'] checkHashStr = '' for key in getResponseParamOrder(): if key in data.keys(): checkHashStr += data[key] checkHash = hmac.new(bytes(settings.secret_key, 'utf-8'), bytes(checkHashStr, 'utf-8'), hashlib.sha256).hexdigest() if hash_value.lower() == checkHash.lower(): # distinguish between various cases # case one: onetime payment response if request.POST['user_defined_1'] and not request.POST[ 'recurring_unique_id']: try: donation = Donation.objects.get( pk=int(request.POST['user_defined_1'])) return Factory_2C2P.initGateway(request, donation, None, data=data) except Donation.DoesNotExist: raise ValueError( _('Cannot identify donation record from 2C2P request, id: %(id)s' ) % {'id': request.POST['user_defined_1']}) # case two: either first time subscription or renewal donation elif request.POST['recurring_unique_id']: try: subscription = Subscription.objects.get( profile_id=str( request.POST['recurring_unique_id']), gateway__title=GATEWAY_2C2P) _debug( '--2C2P initGatewayByVerification: subscription found--' ) # subscription object found, indicating this is a renewal request return Factory_2C2P.initGateway(request, None, subscription, data=data) except Subscription.DoesNotExist: # Subscription object not created yet, indicating this is the first time subscription try: donation = Donation.objects.get( pk=int(request.POST['user_defined_1'])) return Factory_2C2P.initGateway( request, donation, None, data=data, first_time_subscription=True) except Donation.DoesNotExist: raise ValueError( _('Cannot identify donation record from 2C2P request, id: %(id)s' ) % {'id': request.POST['user_defined_1']}) else: _debug("hash_value: " + hash_value) _debug("checkHash: " + checkHash) printvars(data) raise ValueError( _("hash_value does not match with checkHash, cannot verify request from 2C2P." )) else: raise ValueError( _("No hash_value in request.POST, cannot verify request from 2C2P." ))
def process_webhook_response(self): # Event: EVENT_PAYMENT_CAPTURE_COMPLETED (This alone comes after the onetime donation is captured) if self.event_type == EVENT_PAYMENT_CAPTURE_COMPLETED: # update transaction_id self.donation.transaction_id = self.payload['id'] self.donation.save() # payment should have been completed after successful capture at the moment of returning to this site # only run below code if somehow payment_status is still not complete(e.g. donor did not return to site) if self.donation.payment_status != STATUS_COMPLETE: self.donation.payment_status = STATUS_COMPLETE self.donation.save() # send email notifs sendDonationReceiptToDonor(self.donation) sendDonationNotifToAdmins(self.donation) return HttpResponse(status=200) # Event: EVENT_BILLING_SUBSCRIPTION_ACTIVATED if self.event_type == EVENT_BILLING_SUBSCRIPTION_ACTIVATED and hasattr( self, 'subscription_obj'): if self.subscription_obj['status'] == 'ACTIVE': if self.donation.subscription.recurring_status == STATUS_PROCESSING: # save the new subscription, marked by profile_id self.donation.subscription.profile_id = self.subscription_obj[ 'id'] self.donation.subscription.recurring_amount = Decimal( self.subscription_obj['billing_info']['last_payment'] ['amount']['value']) self.donation.subscription.currency = self.subscription_obj[ 'billing_info']['last_payment']['amount'][ 'currency_code'] self.donation.subscription.recurring_status = STATUS_ACTIVE self.donation.subscription.save() # send the new recurring notifs to admins and donor as subscription is just active sendNewRecurringNotifToAdmins(self.donation.subscription) sendNewRecurringNotifToDonor(self.donation.subscription) return HttpResponse(status=200) else: raise ValueError( _("EVENT_BILLING_SUBSCRIPTION_ACTIVATED but subscription status is %(status)s" ) % {'status': self.subscription_obj['status']}) # Event: EVENT_BILLING_SUBSCRIPTION_UPDATED if self.event_type == EVENT_BILLING_SUBSCRIPTION_UPDATED and hasattr( self, 'subscription_obj'): if self.subscription_obj[ 'status'] == 'SUSPENDED' or self.subscription_obj[ 'status'] == 'ACTIVE': subscription = Subscription.objects.filter( profile_id=self.subscription_obj['id']).first() if not subscription: raise ValueError( _("Cannot find subscription object in database with profile_id %(id)s" ) % {'id': self.subscription_obj['id']}) subscription.recurring_amount = Decimal( self.subscription_obj['plan']['billing_cycles'][0] ['pricing_scheme']['fixed_price']['value']) subscription.save() return HttpResponse(status=200) else: raise ValueError( _("EVENT_BILLING_SUBSCRIPTION_UPDATED but subscription status is %(status)s" ) % {'status': self.subscription_obj['status']}) # Event: EVENT_PAYMENT_SALE_COMPLETED if self.event_type == EVENT_PAYMENT_SALE_COMPLETED and hasattr( self, 'subscription_obj'): if self.payload['state'] == 'completed': # check if this is first time subscription payment or a renewal payment donationPMs = DonationPaymentMeta.objects.filter( donation=self.donation, field_key='paypal_first_cycle') renewals = Donation.objects.filter( subscription__profile_id=self.subscription_obj['id']) _debug("Number of donation PMs: " + str(len(donationPMs))) _debug("Number of renewals for subscription_id({}): ".format( self.subscription_obj['id']) + str(len(renewals))) if len(donationPMs) == 1 or len(renewals) >= 2: # this is already a renewal payment # self.donation is the first donation associated with the subscription if not self.donation.subscription: raise ValueError( _("Missing subscription linkage/object for donation %(id)s" ) % {'id': self.donation.id}) donation = Donation( is_test=self.donation.is_test, subscription=self.donation.subscription, transaction_id=self.payload['id'], user=self.donation.user, form=self.donation.form, gateway=self.donation.gateway, is_recurring=True, donation_amount=Decimal( self.payload['amount']['total']), currency=self.payload['amount']['currency'], payment_status=STATUS_COMPLETE, donation_date=datetime.now(timezone.utc), ) # save new donation as a record of renewal donation donation.save() # email notifications sendRenewalReceiptToDonor(donation) sendRenewalNotifToAdmins(donation) else: # this is a first time subscription payment self.donation.payment_status = STATUS_COMPLETE self.donation.transaction_id = self.payload['id'] self.donation.save() # save DonationPaymentMeta as proof of first time subscription payment dpmeta = DonationPaymentMeta( donation=self.donation, field_key='paypal_first_cycle', field_value='completed') dpmeta.save() return HttpResponse(status=200) else: raise ValueError( _("EVENT_PAYMENT_SALE_COMPLETED but payment state is %(state)s" ) % {'state': self.payload['state']}) # Event: EVENT_BILLING_SUBSCRIPTION_CANCELLED if self.event_type == EVENT_BILLING_SUBSCRIPTION_CANCELLED and hasattr( self, 'subscription_obj'): if self.subscription_obj['status'] == 'CANCELLED': self.donation.subscription.recurring_status = STATUS_CANCELLED self.donation.subscription.save() # email notifications sendRecurringCancelledNotifToAdmins(self.donation.subscription) sendRecurringCancelledNotifToDonor(self.donation.subscription) return HttpResponse(status=200) else: raise ValueError( _("EVENT_BILLING_SUBSCRIPTION_CANCELLED but subscription status is %(status)s" ) % {'status': self.subscription_obj['status']}) # return 400 for all other events return HttpResponse(status=400)
def process_webhook_response(self): if self.request.POST.get('txn_type', None) == 'subscr_payment': # copy what process_paypal_subscr_payment does in givewp to here # at this stage, Donation(parent) object is already populated from the custom param # Subscription object should exist also, since it is linked to Donation either via parent_payment_id # update profile_id if it's empty if not self.subscription.profile_id: self.subscription.profile_id = self.request.POST.get( 'subscr_id', None) self.subscription.save() transaction_id_dpm = '' try: # either it returns a record with empty field_value or not found(which raises the DoesNotExist exception) transaction_id_dpm = DonationPaymentMeta.objects.get( donation=self.donation, field_key='_give_payment_transaction_id') except DonationPaymentMeta.DoesNotExist as e: # will re-add transaction_id_dpm down below pass # parse 'payment_date' from ipn_data into python date object for comparison ipn_payment_date = self.request.POST.get('payment_date', None) pac_tz = pytz.timezone('US/Pacific') naive_date = re.sub(r'\s(PST|PDT)$', '', ipn_payment_date) ipn_payment_datetime = pac_tz.localize( datetime.strptime(naive_date, '%H:%M:%S %b %d, %Y')) ipn_payment_datetime_utc = ipn_payment_datetime.astimezone( pytz.utc) is_today = self.subscription.created_at.date( ) == ipn_payment_datetime_utc.date() # Look to see if payment is same day as sign up and we haven't set the transaction ID on the parent payment yet. if is_today and (not transaction_id_dpm or transaction_id_dpm and not transaction_id_dpm.field_value): _debug("[paypal legacy recurring] is_today clause") # Verify the amount paid. initial_amount = round_half_up( self.subscription.recurring_amount, 2) paid_amount = round_half_up( self.request.POST.get('mc_gross', None), 2) if paid_amount < initial_amount: # save DonationPaymentMeta as note of failure, but no need to save parent payment's status to failed dpmeta = DonationPaymentMeta( donation=self.donation, field_key='IPN FAILURE', field_value=str( _('Payment failed due to invalid amount in PayPal Recurring IPN.' ))) dpmeta.save() raise Exception( str( _('Invalid payment amount in IPN subscr_payment response. IPN data: %(data)s' ) % {'data': json.dumps(self.request.POST.dict())})) # This is the very first payment so set the transaction ID. if transaction_id_dpm: transaction_id_dpm.field_value = self.request.POST.get( 'txn_id', '') else: transaction_id_dpm = DonationPaymentMeta( donation=self.donation, field_key='_give_payment_transaction_id', field_value=self.request.POST.get('txn_id', '')) transaction_id_dpm.save() return HttpResponse(status=200) # Is this payment already recorded? try: transaction_id_dpm = DonationPaymentMeta.objects.get( field_key='_give_payment_transaction_id', field_value=self.request.POST.get('txn_id', '')) # Payment already recorded _debug("[paypal legacy recurring] payment already recorded") return HttpResponse(status=200) except DonationPaymentMeta.DoesNotExist as e: # continue code execution pass # add renewal payment to subscription # transaction_id uses the txn_id from paypal directly, just for convenience, value is the same as _give_payment_transaction_id in donationPaymentMeta renewal = Donation( is_test=self.testing_mode, subscription=self.donation.subscription, transaction_id=self.request.POST.get('txn_id') if self.request.POST.get('txn_id', '') else gen_transaction_id( self.donation.gateway), user=self.donation.user, form=self.donation.form, gateway=self.donation.gateway, is_recurring=True, donation_amount=round_half_up( self.request.POST.get('mc_gross', None), 2), currency=self.donation.currency, payment_status=STATUS_COMPLETE, donation_date=datetime.now(timezone.utc), ) # save new donation as a record of renewal donation renewal.save() # email notifications sendRenewalReceiptToDonor(renewal) sendRenewalNotifToAdmins(renewal) # also save required DonationPaymentMetas, purchase key is skipped here as it should be saved at the parent payment/subscription transaction_id_dpm = DonationPaymentMeta( donation=renewal, field_key='_give_payment_transaction_id', field_value=self.request.POST.get('txn_id', '')) transaction_id_dpm.save() # skip out the renew method from givewp as we don't do that here return HttpResponse(status=200) elif self.request.POST.get('txn_type', None) == 'subscr_cancel': self.donation.subscription.recurring_status = STATUS_CANCELLED self.donation.subscription.save() # email notifications sendRecurringCancelledNotifToDonor(self.donation.subscription) sendRecurringCancelledNotifToAdmins(self.donation.subscription) return HttpResponse(status=200) else: _debug("[paypal legacy recurring] ipn txn_type not handled: " + self.request.POST.get('txn_type', None)) return HttpResponse(status=400)