2020-05-12 17:40:10 +00:00
import stripe
import json
2020-05-14 17:41:12 +00:00
import time
2020-05-12 17:40:10 +00:00
import decimal
2020-05-22 20:20:26 +00:00
import re
2020-07-18 15:16:17 +00:00
import sys
2020-05-12 17:40:10 +00:00
2020-05-22 20:20:26 +00:00
from time import sleep
2022-02-08 23:16:52 +00:00
from datetime import datetime , timedelta
2020-05-12 17:40:10 +00:00
from flask import Blueprint
2020-05-22 20:20:26 +00:00
from flask import make_response
2020-05-12 17:40:10 +00:00
from flask import request
from flask import current_app
from flask import session
from flask import redirect
from flask import url_for
from flask import jsonify
from flask import flash
from flask import render_template
from werkzeug . exceptions import abort
from capsulflask . auth import account_required
2021-01-04 19:32:52 +00:00
from capsulflask . db import get_model
2022-02-22 20:04:38 +00:00
from capsulflask . btcpay import client as btcpay
2022-02-08 23:44:31 +00:00
from capsulflask . shared import my_exec_info_message , get_account_balance , average_number_of_days_in_a_month
2020-05-12 17:40:10 +00:00
2020-05-14 23:03:00 +00:00
bp = Blueprint ( " payment " , __name__ , url_prefix = " /payment " )
2020-05-12 17:40:10 +00:00
2021-12-14 07:46:33 +00:00
def validate_dollars ( min : float , max : float ) :
2020-05-12 18:04:35 +00:00
errors = list ( )
2020-05-14 23:03:00 +00:00
dollars = None
if " dollars " not in request . form :
errors . append ( " dollars is required " )
else :
2020-05-12 18:04:35 +00:00
dollars = None
try :
2021-12-14 07:46:33 +00:00
dollars = float ( request . form [ " dollars " ] )
2020-05-12 18:04:35 +00:00
except :
errors . append ( " dollars must be a number " )
2021-12-14 07:35:17 +00:00
2021-12-14 07:41:35 +00:00
#current_app.logger.info(f"{str(dollars)} {str(min)} {str(dollars < min)}")
2021-12-14 07:35:17 +00:00
2021-12-14 07:47:45 +00:00
if dollars is not None and dollars < min :
2021-12-14 07:42:26 +00:00
errors . append ( f " dollars must be { format ( min , ' .2f ' ) } or more " )
2021-12-14 07:47:45 +00:00
elif dollars is not None and dollars > = max :
2021-12-14 07:42:26 +00:00
errors . append ( f " dollars must be less than { format ( max , ' .2f ' ) } " )
2020-05-14 23:03:00 +00:00
2021-12-14 07:37:39 +00:00
current_app . logger . info ( f " { len ( errors ) } { errors } " )
2021-12-14 07:46:33 +00:00
return ( dollars , errors )
2020-05-14 23:03:00 +00:00
@bp.route ( " /btcpay " , methods = ( " GET " , " POST " ) )
@account_required
def btcpay_payment ( ) :
errors = list ( )
2022-02-22 20:04:38 +00:00
if current_app . config [ ' BTCPAY_URL ' ] == " " :
2021-07-20 22:22:58 +00:00
flash ( " BTCPay is not enabled on this server " )
return redirect ( url_for ( " console.account_balance " ) )
2022-02-22 20:04:38 +00:00
elif ' BTCPAY_CLIENT ' not in current_app . config :
if not try_reconnnect_btcpay ( ) :
flash ( " can ' t contact the BTCPay server right now " )
return redirect ( url_for ( " console.account_balance " ) )
2021-07-20 22:22:58 +00:00
2020-05-14 23:03:00 +00:00
if request . method == " POST " :
2021-12-14 07:46:33 +00:00
dollars , errors = validate_dollars ( 0.01 , 1000.0 )
2021-12-14 07:41:35 +00:00
#current_app.logger.info(f"{len(errors)} {errors}")
2020-05-14 23:03:00 +00:00
if len ( errors ) == 0 :
2021-09-02 17:00:48 +00:00
try :
invoice = current_app . config [ ' BTCPAY_CLIENT ' ] . create_invoice ( dict (
price = float ( dollars ) ,
currency = " USD " ,
itemDesc = " Capsul Cloud Compute " ,
transactionSpeed = " high " ,
redirectURL = f " { current_app . config [ ' BASE_URL ' ] } /console/account-balance " ,
notificationURL = f " { current_app . config [ ' BASE_URL ' ] } /payment/btcpay/webhook "
) )
except :
current_app . logger . error ( f " An error occurred while attempting to reach BTCPay Server: { my_exec_info_message ( sys . exc_info ( ) ) } " )
flash ( " An error occurred while attempting to reach BTCPay Server. " )
return redirect ( url_for ( " console.account_balance " ) )
2020-05-17 02:28:28 +00:00
current_app . logger . info ( f " created btcpay invoice: { invoice } " )
2020-05-14 23:03:00 +00:00
# print(invoice)
2021-07-08 02:12:31 +00:00
current_app . logger . info ( f " created btcpay invoice_id= { invoice [ ' id ' ] } ( { session [ ' account ' ] } , $ { dollars } ) " )
2020-05-14 23:03:00 +00:00
2021-07-08 02:04:23 +00:00
get_model ( ) . create_payment_session ( " btcpay " , invoice [ " id " ] , session [ " account " ] , dollars )
2020-05-14 23:03:00 +00:00
return redirect ( invoice [ " url " ] )
for error in errors :
flash ( error )
2021-07-08 02:04:23 +00:00
return render_template ( " btcpay.html " )
2020-05-14 23:03:00 +00:00
2020-05-17 03:04:51 +00:00
def poll_btcpay_session ( invoice_id ) :
2022-02-22 20:04:38 +00:00
if ' BTCPAY_CLIENT ' not in current_app . config :
if not try_reconnnect_btcpay ( ) :
return [ 503 , " can ' t contact btcpay server " ]
2020-07-18 15:16:17 +00:00
invoice = None
try :
invoice = current_app . config [ ' BTCPAY_CLIENT ' ] . get_invoice ( invoice_id )
except :
current_app . logger . error ( f """
error was thrown when contacting btcpay server :
{ my_exec_info_message ( sys . exc_info ( ) ) } """
)
return [ 503 , " error was thrown when contacting btcpay server " ]
2020-05-15 04:40:27 +00:00
if invoice [ ' currency ' ] != " USD " :
2020-05-17 03:04:51 +00:00
return [ 400 , " invalid currency " ]
2020-05-15 04:40:27 +00:00
dollars = invoice [ ' price ' ]
2020-07-18 15:16:17 +00:00
current_app . logger . info ( f " poll_btcpay_session invoice_id= { invoice_id } , status= { invoice [ ' status ' ] } dollars= { dollars } " )
2020-05-17 02:24:40 +00:00
2020-05-15 23:18:19 +00:00
if invoice [ ' status ' ] == " paid " or invoice [ ' status ' ] == " confirmed " or invoice [ ' status ' ] == " complete " :
2020-05-15 04:40:27 +00:00
success_account = get_model ( ) . consume_payment_session ( " btcpay " , invoice_id , dollars )
if success_account :
2020-05-16 04:19:01 +00:00
current_app . logger . info ( f " { success_account } paid $ { dollars } successfully (btcpay_invoice_id= { invoice_id } ) " )
2020-05-15 04:40:27 +00:00
2020-05-15 23:18:19 +00:00
if invoice [ ' status ' ] == " complete " :
2022-02-08 23:16:52 +00:00
resolved_invoice_email = get_model ( ) . btcpay_invoice_resolved ( invoice_id , True )
if resolved_invoice_email is not None :
check_if_shortterm_flag_can_be_unset ( resolved_invoice_email )
2020-05-15 04:40:27 +00:00
elif invoice [ ' status ' ] == " expired " or invoice [ ' status ' ] == " invalid " :
get_model ( ) . btcpay_invoice_resolved ( invoice_id , False )
2020-07-18 15:16:17 +00:00
get_model ( ) . delete_payment_session ( " btcpay " , invoice_id )
2020-05-15 23:18:19 +00:00
2020-05-17 03:04:51 +00:00
return [ 200 , " ok " ]
2022-02-22 20:04:38 +00:00
def try_reconnnect_btcpay ( ) :
try :
response = requests . get ( current_app . config [ ' BTCPAY_URL ' ] )
if response . status_code == 200 :
current_app . config [ ' BTCPAY_CLIENT ' ] = btcpay . Client ( api_uri = current_app . config [ ' BTCPAY_URL ' ] , pem = current_app . config [ ' BTCPAY_PRIVATE_KEY ' ] )
return True
else :
current_app . logger . warn ( f " Can ' t reach BTCPAY_URL { current_app . config [ ' BTCPAY_URL ' ] } : Response status code: { response . status_code } . Capsul will work fine except cryptocurrency payments will not work. " )
return False
except :
current_app . logger . warn ( " unable to create btcpay client. Capsul will work fine except cryptocurrency payments will not work. The error was: " + my_exec_info_message ( sys . exc_info ( ) ) )
return False
2020-05-17 03:04:51 +00:00
@bp.route ( " /btcpay/webhook " , methods = ( " POST " , ) )
def btcpay_webhook ( ) :
current_app . logger . info ( f " got btcpay webhook " )
2021-03-23 20:57:07 +00:00
# IMPORTANT! there is no signature or credential to authenticate the data sent into this webhook :facepalm:
2020-05-17 03:04:51 +00:00
# its just a notification, thats all.
request_data = json . loads ( request . data )
invoice_id = request_data [ ' id ' ]
2021-03-23 20:57:07 +00:00
# so you better make sure to get the invoice data directly from the horses mouth!
2020-05-17 03:04:51 +00:00
result = poll_btcpay_session ( invoice_id )
2021-03-23 20:57:07 +00:00
return result [ 1 ] , result [ 0 ]
2020-05-15 04:40:27 +00:00
2022-02-08 23:16:52 +00:00
# if the user has any shortterm vms (vms which were created at a time when they could not pay for that vm for 1 month)
# then it would be nice if those vms could be "promoted" to long-term if they pay up enough to cover costs for 1 month
def check_if_shortterm_flag_can_be_unset ( email : str ) :
vms = get_model ( ) . list_vms_for_account ( email )
payments = get_model ( ) . list_payments_for_account ( email )
balance = get_account_balance ( vms , payments , datetime . utcnow ( ) + timedelta ( days = average_number_of_days_in_a_month ) )
if balance > 0 :
get_model ( ) . clear_shortterm_flag ( email )
2020-05-15 04:40:27 +00:00
2020-05-14 23:03:00 +00:00
@bp.route ( " /stripe " , methods = ( " GET " , " POST " ) )
@account_required
def stripe_payment ( ) :
stripe_checkout_session_id = None
errors = list ( )
if request . method == " POST " :
2021-12-14 07:46:33 +00:00
dollars , errors = validate_dollars ( 0.5 , 1000000 )
2020-05-12 17:40:10 +00:00
if len ( errors ) == 0 :
2020-05-16 04:19:01 +00:00
current_app . logger . info ( f " creating stripe checkout session for { session [ ' account ' ] } , $ { dollars } " )
2020-05-12 17:40:10 +00:00
checkout_session = stripe . checkout . Session . create (
2020-05-14 23:03:00 +00:00
success_url = current_app . config [ ' BASE_URL ' ] + " /payment/stripe/success?session_id= {CHECKOUT_SESSION_ID} " ,
cancel_url = current_app . config [ ' BASE_URL ' ] + " /payment/stripe " ,
2020-05-12 17:40:10 +00:00
payment_method_types = [ " card " ] ,
2020-05-15 23:18:19 +00:00
#customer_email=session["account"],
2020-05-12 17:40:10 +00:00
line_items = [
{
" name " : " Capsul Cloud Compute " ,
" images " : [ current_app . config [ ' BASE_URL ' ] + " /static/capsul-product-image.png " ] ,
" quantity " : 1 ,
" currency " : " usd " ,
2020-05-12 18:04:35 +00:00
" amount " : int ( dollars * 100 )
2020-05-12 17:40:10 +00:00
}
]
)
stripe_checkout_session_id = checkout_session [ ' id ' ]
2020-05-16 04:19:01 +00:00
current_app . logger . info ( f " stripe_checkout_session_id= { stripe_checkout_session_id } ( { session [ ' account ' ] } , $ { dollars } ) " )
2020-05-12 17:40:10 +00:00
2020-05-14 23:03:00 +00:00
get_model ( ) . create_payment_session ( " stripe " , stripe_checkout_session_id , session [ " account " ] , dollars )
2020-05-12 17:40:10 +00:00
2020-05-15 23:18:19 +00:00
# We can't do this because stripe requires a bunch of server-authenticated data to be sent in the hash
# of the URL. I briefly looked into reverse-engineering their proprietary javascript in order to try to figure out
# how it works and gave up after I discovered that it would require multiple complex interactions with stripe's
# servers, and it looked like they were trying to make what I was trying to do impossible.
# I never tried running the stripe proprietary javascript in a headless brower and passing the hash from the
# headless browser to the client, but I suspect it might not work anyway because they probably have thier tracking
# cookie info in there somewhere, and if the cookie doesn't match they may refuse to display the page.
#return redirect(f"https://checkout.stripe.com/pay/{stripe_checkout_session_id}")
2020-05-22 20:20:26 +00:00
return redirect ( f " /payment/stripe/ { stripe_checkout_session_id } " )
2020-05-12 17:40:10 +00:00
for error in errors :
flash ( error )
2020-05-22 20:20:26 +00:00
return render_template ( " stripe.html " )
@bp.route ( " /stripe/<string:stripe_checkout_session_id> " )
@account_required
def redirect_to_stripe ( stripe_checkout_session_id ) :
if stripe_checkout_session_id and not re . match ( r " ^[a-zA-Z0-9_=-]+$ " , stripe_checkout_session_id ) :
stripe_checkout_session_id = ' ___________ '
response = make_response ( render_template (
" stripe.html " ,
2020-05-12 17:40:10 +00:00
stripe_checkout_session_id = stripe_checkout_session_id ,
stripe_public_key = current_app . config [ " STRIPE_PUBLISHABLE_KEY " ]
2020-05-22 20:20:26 +00:00
) )
2020-05-22 20:31:53 +00:00
response . headers [ ' Content-Security-Policy ' ] = " default-src ' self ' https://js.stripe.com "
2020-05-22 20:20:26 +00:00
return response
@bp.route ( " /stripe/<string:stripe_checkout_session_id>/json " )
@account_required
def stripe_checkout_session_json ( stripe_checkout_session_id ) :
if stripe_checkout_session_id and not re . match ( r " ^[a-zA-Z0-9_=-]+$ " , stripe_checkout_session_id ) :
stripe_checkout_session_id = ' ___________ '
has_redirected_already = get_model ( ) . payment_session_redirect ( session [ ' account ' ] , stripe_checkout_session_id )
if has_redirected_already is None :
abort ( 404 , " Not Found " )
return jsonify ( dict ( hasRedirectedAlready = has_redirected_already ) )
2020-05-12 17:40:10 +00:00
2020-05-15 23:18:19 +00:00
def validate_stripe_checkout_session ( stripe_checkout_session_id ) :
checkout_session_completed_events = stripe . Event . list (
type = ' checkout.session.completed ' ,
created = {
# Check for events created in the last half hour
' gte ' : int ( time . time ( ) - ( 30 * 60 ) ) ,
} ,
)
for event in checkout_session_completed_events . auto_paging_iter ( ) :
checkout_session = event [ ' data ' ] [ ' object ' ]
if checkout_session and ' id ' in checkout_session and checkout_session [ ' id ' ] == stripe_checkout_session_id :
2020-11-02 20:36:01 +00:00
cents = checkout_session [ ' amount_total ' ]
2020-05-15 23:18:19 +00:00
dollars = decimal . Decimal ( cents ) / 100
#consume_payment_session deletes the checkout session row and inserts a payment row
# its ok to call consume_payment_session more than once because it only takes an action if the session exists
success_email = get_model ( ) . consume_payment_session ( " stripe " , stripe_checkout_session_id , dollars )
2022-02-08 23:16:52 +00:00
if success_email is not None :
check_if_shortterm_flag_can_be_unset ( success_email )
2020-05-15 23:18:19 +00:00
if success_email :
return dict ( email = success_email , dollars = dollars )
return None
2020-05-14 23:03:00 +00:00
@bp.route ( " /stripe/success " , methods = ( " GET " , ) )
2020-05-12 17:40:10 +00:00
def success ( ) :
stripe_checkout_session_id = request . args . get ( ' session_id ' )
if not stripe_checkout_session_id :
2020-05-16 04:19:01 +00:00
current_app . logger . info ( " /payment/stripe/success returned 400: missing required URL parameter session_id " )
2020-05-12 17:40:10 +00:00
abort ( 400 , " missing required URL parameter session_id " )
else :
2020-05-15 23:18:19 +00:00
for _ in range ( 0 , 5 ) :
paid = validate_stripe_checkout_session ( stripe_checkout_session_id )
if paid :
2020-05-16 04:19:01 +00:00
current_app . logger . info ( f " { paid [ ' email ' ] } paid $ { paid [ ' dollars ' ] } successfully (stripe_checkout_session_id= { stripe_checkout_session_id } ) " )
2022-02-08 23:16:52 +00:00
2020-05-14 17:41:12 +00:00
return redirect ( url_for ( " console.account_balance " ) )
2020-05-15 23:18:19 +00:00
else :
sleep ( 1 )
2020-05-14 17:41:12 +00:00
abort ( 400 , " this checkout session is not paid yet " )
# webhook is not needed
2020-05-12 17:40:10 +00:00
# @bp.route("/webhook", methods=("POST",))
# def webhook():
# request_data = json.loads(request.data)
# signature = request.headers.get('stripe-signature')
# try:
# event = stripe.Webhook.construct_event(
# payload=request_data,
# sig_header=signature,
# secret=current_app.config['STRIPE_WEBHOOK_SECRET']
# )
# if event['type'] == 'checkout.session.completed':
2020-11-02 20:36:01 +00:00
# dollars = event['data']['object']['amount_total']
2020-05-12 17:40:10 +00:00
# stripe_checkout_session_id = event['data']['object']['id']
2020-05-14 23:03:00 +00:00
# #consume_payment_session deletes the checkout session row and inserts a payment row
# # its ok to call consume_payment_session more than once because it only takes an action if the session exists
2020-05-15 04:40:27 +00:00
# success_account = get_model().consume_payment_session("stripe", stripe_checkout_session_id, dollars)
2020-05-14 17:41:12 +00:00
# if success_account:
# print(f"{success_account} paid ${dollars} successfully (stripe_checkout_session_id={stripe_checkout_session_id})")
2020-05-12 17:40:10 +00:00
# return jsonify({'status': 'success'})
# except ValueError as e:
2020-05-14 23:03:00 +00:00
# print("/payment/stripe/webhook returned 400: bad request", e)
2020-05-12 17:40:10 +00:00
# abort(400, "bad request")
# except stripe.error.SignatureVerificationError:
2020-05-14 23:03:00 +00:00
# print("/payment/stripe/webhook returned 400: invalid signature")
2020-05-12 17:40:10 +00:00
# abort(400, "invalid signature")
2021-07-20 22:22:58 +00:00