capsul-flask/capsulflask/payment.py

345 lines
13 KiB
Python
Raw Normal View History

2020-05-12 17:40:10 +00:00
import stripe
import requests
2020-05-12 17:40:10 +00:00
import json
import time
2020-05-12 17:40:10 +00:00
import decimal
import re
import sys
2020-05-12 17:40:10 +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
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
from capsulflask.db import get_model
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
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()
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
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")
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')}")
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)
@bp.route("/btcpay", methods=("GET", "POST"))
@account_required
def btcpay_payment():
errors = list()
if current_app.config['BTCPAY_URL'] == "":
flash("BTCPay is not enabled on this server")
return redirect(url_for("console.account_balance"))
elif 'BTCPAY_CLIENT' not in current_app.config:
if not try_reconnnect_btcpay(current_app):
flash("can't contact the BTCPay server right now")
return redirect(url_for("console.account_balance"))
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}")
if len(errors) == 0:
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}")
# print(invoice)
current_app.logger.info(f"created btcpay invoice_id={invoice['id']} ( {session['account']}, ${dollars} )")
get_model().create_payment_session("btcpay", invoice["id"], session["account"], dollars)
return redirect(invoice["url"])
for error in errors:
flash(error)
return render_template("btcpay.html")
def poll_btcpay_session(invoice_id):
if 'BTCPAY_CLIENT' not in current_app.config:
if not try_reconnnect_btcpay(current_app):
return [503, "can't contact btcpay server"]
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":
return [400, "invalid currency"]
2020-05-15 04:40:27 +00:00
dollars = invoice['price']
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
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
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)
get_model().delete_payment_session("btcpay", invoice_id)
return [200, "ok"]
def try_reconnnect_btcpay(app):
try:
response = requests.get(app.config['BTCPAY_URL'])
if response.status_code == 200:
app.config['BTCPAY_CLIENT'] = btcpay.Client(api_uri=app.config['BTCPAY_URL'], pem=app.config['BTCPAY_PRIVATE_KEY'])
return True
else:
app.logger.warn(f"Can't reach BTCPAY_URL {app.config['BTCPAY_URL']}: Response status code: {response.status_code}. Capsul will work fine except cryptocurrency payments will not work.")
return False
except:
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
@bp.route("/btcpay/webhook", methods=("POST",))
def btcpay_webhook():
current_app.logger.info(f"got btcpay webhook")
# IMPORTANT! there is no signature or credential to authenticate the data sent into this webhook :facepalm:
# its just a notification, thats all.
request_data = json.loads(request.data)
invoice_id = request_data['id']
# so you better make sure to get the invoice data directly from the horses mouth!
result = poll_btcpay_session(invoice_id)
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
@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(
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"],
#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
get_model().create_payment_session("stripe", stripe_checkout_session_id, session["account"], dollars)
2020-05-12 17:40:10 +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}")
return redirect(f"/payment/stripe/{stripe_checkout_session_id}")
2020-05-12 17:40:10 +00:00
for error in errors:
flash(error)
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:31:53 +00:00
response.headers['Content-Security-Policy'] = "default-src 'self' https://js.stripe.com"
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
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:
cents = checkout_session['amount_total']
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)
if success_email:
return dict(email=success_email, dollars=dollars)
return None
@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:
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
return redirect(url_for("console.account_balance"))
else:
sleep(1)
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':
# dollars = event['data']['object']['amount_total']
2020-05-12 17:40:10 +00:00
# stripe_checkout_session_id = event['data']['object']['id']
# #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)
# 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:
# 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:
# print("/payment/stripe/webhook returned 400: invalid signature")
2020-05-12 17:40:10 +00:00
# abort(400, "invalid signature")