diff --git a/README.md b/README.md index 4f94cb4..ca5e41a 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,8 @@ Run the app in gunicorn .venv/bin/gunicorn --bind 127.0.0.1:5000 capsulflask:app ``` -## postgres database schema management + +# postgres database schema management capsulflask has a concept of a schema version. When the application starts, it will query the database for a table named `schemaversion` that has one row and one column (`version`). If the `version` it finds is not equal to the `desiredSchemaVersion` variable set in `db.py`, it will run migration scripts from the `schema_migrations` folder one by one until the `schemaversion` table shows the correct version. @@ -62,7 +63,8 @@ For example, the script named `02_up_xyz.sql` should contain code that migrates In general, for safety, schema version upgrades should not delete data. Schema version downgrades will simply throw an error and exit for now. -## how to setup btcpay server + +# how to setup btcpay server Generate a private key and the accompanying bitpay SIN for the bitpay API client. diff --git a/capsulflask/__init__.py b/capsulflask/__init__.py index 8cd5c03..1e5c1e7 100644 --- a/capsulflask/__init__.py +++ b/capsulflask/__init__.py @@ -1,6 +1,8 @@ -import stripe + import os +import stripe +from bitpay import client as btcpay from dotenv import load_dotenv, find_dotenv from flask import Flask from flask_mail import Mail @@ -31,7 +33,8 @@ app.config.from_mapping( STRIPE_PUBLISHABLE_KEY=os.environ.get("STRIPE_PUBLISHABLE_KEY", default=""), #STRIPE_WEBHOOK_SECRET=os.environ.get("STRIPE_WEBHOOK_SECRET", default="") - BTCPAY_PRIVATE_KEY=os.environ.get("BTCPAY_PRIVATE_KEY", default="") + BTCPAY_PRIVATE_KEY=os.environ.get("BTCPAY_PRIVATE_KEY", default=""), + BTCPAY_URL=os.environ.get("BTCPAY_URL", default="https://btcpay.cyberia.club") ) stripe.api_key = app.config['STRIPE_SECRET_KEY'] @@ -39,18 +42,18 @@ stripe.api_version = app.config['STRIPE_API_VERSION'] app.config['FLASK_MAIL_INSTANCE'] = Mail(app) app.config['VIRTUALIZATION_MODEL'] = virt_model.MockVirtualization() +app.config['BTCPAY_CLIENT'] = btcpay.Client(api_uri=app.config['BTCPAY_URL'], pem=app.config['BTCPAY_PRIVATE_KEY']) from capsulflask import db db.init_app(app) -from capsulflask import auth, landing, console, payment_stripe, payment_btcpay, metrics +from capsulflask import auth, landing, console, payment, metrics app.register_blueprint(landing.bp) app.register_blueprint(auth.bp) app.register_blueprint(console.bp) -app.register_blueprint(payment_stripe.bp) -app.register_blueprint(payment_btcpay.bp) +app.register_blueprint(payment.bp) app.register_blueprint(metrics.bp) app.add_url_rule("/", endpoint="index") diff --git a/capsulflask/console.py b/capsulflask/console.py index 0cb0e3e..12f6005 100644 --- a/capsulflask/console.py +++ b/capsulflask/console.py @@ -254,7 +254,7 @@ def get_account_balance(): vm_months = ( end_datetime - vm["created"] ).days / average_number_of_days_in_a_month vm_cost_dollars += vm_months * float(vm["dollars_per_month"]) - payment_dollars_total = float( sum(map(lambda x: x["dollars"], get_payments())) ) + payment_dollars_total = float( sum(map(lambda x: 0 if x["invalidated"] else x["dollars"], get_payments())) ) return payment_dollars_total - vm_cost_dollars @@ -268,8 +268,8 @@ def account_balance(): for vm in get_vms(): end_datetime = vm["deleted"] if vm["deleted"] else datetime.utcnow() - print(end_datetime) - print(vm["created"]) + # print(end_datetime) + # print(vm["created"]) vm_months = (end_datetime - vm["created"]).days / average_number_of_days_in_a_month vms_billed.append(dict( id=vm["id"], @@ -284,7 +284,14 @@ def account_balance(): "account-balance.html", has_vms=len(vms_billed)>0, vms_billed=vms_billed, - payments=list(map(lambda x: dict(dollars=x["dollars"], created=x["created"].strftime("%b %d %Y")), payments)), + payments=list(map( + lambda x: dict( + dollars=x["dollars"], + class_name="invalidated" if x["invalidated"] else "", + created=x["created"].strftime("%b %d %Y") + ), + payments + )), has_payments=len(payments)>0, account_balance=format(account_balance, '.2f') ) \ No newline at end of file diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py index a14f334..07f1867 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -146,38 +146,38 @@ class DBModel: def list_payments_for_account(self, email): self.cursor.execute(""" - SELECT payments.dollars, payments.created + SELECT dollars, invalidated, created FROM payments WHERE payments.email = %s""", (email, ) ) return list(map( - lambda x: dict(dollars=x[0], created=x[1]), + lambda x: dict(dollars=x[0], invalidated=x[1], created=x[2]), self.cursor.fetchall() )) - def create_stripe_checkout_session(self, id, email, dollars): + def create_payment_session(self, payment_type, id, email, dollars): self.cursor.execute(""" - INSERT INTO stripe_checkout_sessions (id, email, dollars) - VALUES (%s, %s, %s) + INSERT INTO payment_sessions (id, type, email, dollars) + VALUES (%s, %s, %s, %s) """, - (id, email, dollars) + (id, payment_type, email, dollars) ) self.connection.commit() - def consume_stripe_checkout_session(self, id, dollars): - self.cursor.execute("SELECT email, dollars FROM stripe_checkout_sessions WHERE id = %s", (id,)) + def consume_payment_session(self, payment_type, id, dollars): + self.cursor.execute("SELECT email, dollars FROM payment_sessions WHERE id = %s AND type = %s", (id, payment_type)) rows = self.cursor.fetchall() if len(rows) > 0: if int(rows[0][1]) != int(dollars): print(f""" - Stripe sent us a completed checkout session with a different dollar amount than what we had recorded!! - stripe_checkout_session_id: {id} + {payment_type} gave us a completed payment session with a different dollar amount than what we had recorded!! + id: {id} account: {rows[0][0]} Recorded amount: {int(rows[0][1])} - Stripe sent: {int(dollars)} + {payment_type} sent: {int(dollars)} """) # not sure what to do here. For now just log and do nothing. - self.cursor.execute( "DELETE FROM stripe_checkout_sessions WHERE id = %s", (id,) ) + self.cursor.execute( "DELETE FROM payment_sessions WHERE id = %s AND type = %s", (id, payment_type) ) self.cursor.execute( "INSERT INTO payments (email, dollars) VALUES (%s, %s)", (rows[0][0], rows[0][1]) ) self.connection.commit() return rows[0][0] diff --git a/capsulflask/payment_stripe.py b/capsulflask/payment.py similarity index 56% rename from capsulflask/payment_stripe.py rename to capsulflask/payment.py index 4a6081d..b76332e 100644 --- a/capsulflask/payment_stripe.py +++ b/capsulflask/payment.py @@ -18,34 +18,79 @@ from capsulflask.auth import account_required from capsulflask.db import get_model -bp = Blueprint("stripe", __name__, url_prefix="/stripe") +bp = Blueprint("payment", __name__, url_prefix="/payment") -@bp.route("/", methods=("GET", "POST")) -@account_required -def index(): - - stripe_checkout_session_id=None +def validate_dollars(): errors = list() - - if request.method == "POST": - if "dollars" not in request.form: - errors.append("dollars is required") + dollars = None + if "dollars" not in request.form: + errors.append("dollars is required") + else: dollars = None try: dollars = decimal.Decimal(request.form["dollars"]) except: errors.append("dollars must be a number") - if dollars and dollars < decimal.Decimal(1): - errors.append("dollars must be >= 1") + # TODO re enable this + # if dollars and dollars < decimal.Decimal(1): + # errors.append("dollars must be >= 1") + + return [errors, dollars] + +@bp.route("/btcpay", methods=("GET", "POST")) +@account_required +def btcpay_payment(): + errors = list() + invoice_id = None + + if request.method == "POST": + result = validate_dollars() + errors = result[0] + dollars = result[1] + + if len(errors) == 0: + 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']}/account-balance" + )) + # print(invoice) + invoice_id = invoice["id"] + + print(f"created btcpay invoice_id={invoice_id} ( {session['account']}, ${request.form['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", invoice_id=invoice_id) + + +@bp.route("/stripe", methods=("GET", "POST")) +@account_required +def stripe_payment(): + + stripe_checkout_session_id=None + errors = list() + + if request.method == "POST": + result = validate_dollars() + errors = result[0] + dollars = result[1] if len(errors) == 0: - print(f"creating stripe checkout session for {session['account']}, ${request.form['dollars']}") + print(f"creating stripe checkout session for {session['account']}, ${dollars}") checkout_session = stripe.checkout.Session.create( - success_url=current_app.config['BASE_URL'] + "/stripe/success?session_id={CHECKOUT_SESSION_ID}", - cancel_url=current_app.config['BASE_URL'] + "/stripe", + success_url=current_app.config['BASE_URL'] + "/payment/stripe/success?session_id={CHECKOUT_SESSION_ID}", + cancel_url=current_app.config['BASE_URL'] + "/payment/stripe", payment_method_types=["card"], customer_email=session["account"], line_items=[ @@ -60,9 +105,9 @@ def index(): ) stripe_checkout_session_id = checkout_session['id'] - print(f"stripe_checkout_session_id={stripe_checkout_session_id} ( {session['account']}, ${request.form['dollars']} )") + print(f"stripe_checkout_session_id={stripe_checkout_session_id} ( {session['account']}, ${dollars} )") - get_model().create_stripe_checkout_session(stripe_checkout_session_id, session["account"], request.form["dollars"]) + get_model().create_payment_session("stripe", stripe_checkout_session_id, session["account"], dollars) for error in errors: flash(error) @@ -73,11 +118,11 @@ def index(): stripe_public_key=current_app.config["STRIPE_PUBLISHABLE_KEY"] ) -@bp.route("/success", methods=("GET",)) +@bp.route("/stripe/success", methods=("GET",)) def success(): stripe_checkout_session_id = request.args.get('session_id') if not stripe_checkout_session_id: - print("/stripe/success returned 400: missing required URL parameter session_id") + print("/payment/stripe/success returned 400: missing required URL parameter session_id") abort(400, "missing required URL parameter session_id") else: checkout_session_completed_events = stripe.Event.list( @@ -95,9 +140,9 @@ def success(): cents = checkout_session['display_items'][0]['amount'] dollars = decimal.Decimal(cents)/100 - #consume_stripe_checkout_session deletes the checkout session row and inserts a payment row - # its ok to call consume_stripe_checkout_session more than once because it only takes an action if the session exists - success_account = get_model().consume_stripe_checkout_session(stripe_checkout_session_id, dollars) + #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_account = get_model().consume_payment_session(stripe_checkout_session_id, dollars) if success_account: print(f"{success_account} paid ${dollars} successfully (stripe_checkout_session_id={stripe_checkout_session_id})") @@ -121,17 +166,17 @@ def success(): # dollars = event['data']['object']['display_items'][0]['amount'] # stripe_checkout_session_id = event['data']['object']['id'] -# #consume_stripe_checkout_session deletes the checkout session row and inserts a payment row -# # its ok to call consume_stripe_checkout_session more than once because it only takes an action if the session exists -# success_account = get_model().consume_stripe_checkout_session(stripe_checkout_session_id, dollars) +# #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_account = get_model().consume_payment_session(stripe_checkout_session_id, dollars) # if success_account: # print(f"{success_account} paid ${dollars} successfully (stripe_checkout_session_id={stripe_checkout_session_id})") # return jsonify({'status': 'success'}) # except ValueError as e: -# print("/stripe/webhook returned 400: bad request", e) +# print("/payment/stripe/webhook returned 400: bad request", e) # abort(400, "bad request") # except stripe.error.SignatureVerificationError: -# print("/stripe/webhook returned 400: invalid signature") +# print("/payment/stripe/webhook returned 400: invalid signature") # abort(400, "invalid signature") \ No newline at end of file diff --git a/capsulflask/payment_btcpay.py b/capsulflask/payment_btcpay.py deleted file mode 100644 index 9c10a1d..0000000 --- a/capsulflask/payment_btcpay.py +++ /dev/null @@ -1,12 +0,0 @@ -from flask import Blueprint -from flask import render_template - -from capsulflask.db import get_model -from capsulflask.auth import account_required - -bp = Blueprint("btcpay", __name__, url_prefix="/btcpay") - -@bp.route("/") -@account_required -def index(): - return render_template("btcpay.html") diff --git a/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql b/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql index 2c0f131..12e1997 100644 --- a/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql +++ b/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql @@ -52,6 +52,7 @@ CREATE TABLE payments ( email TEXT REFERENCES accounts(email) ON DELETE RESTRICT, created TIMESTAMP NOT NULL DEFAULT NOW(), dollars NUMERIC(8, 2) NOT NULL, + invalidated BOOLEAN NOT NULL DEFAULT FALSE, PRIMARY KEY (email, created) ); @@ -62,13 +63,21 @@ CREATE TABLE login_tokens ( PRIMARY KEY (email, created) ); -CREATE TABLE stripe_checkout_sessions ( +CREATE TABLE payment_sessions ( id TEXT PRIMARY KEY, email TEXT REFERENCES accounts(email) ON DELETE RESTRICT, + type TEXT NOT NULL, created TIMESTAMP NOT NULL DEFAULT NOW(), dollars NUMERIC(8, 2) NOT NULL ); +CREATE TABLE unconfirmed_btcpay_invoices ( + id TEXT PRIMARY KEY, + email TEXT REFERENCES accounts(email) ON DELETE RESTRICT, + created TIMESTAMP NOT NULL, + FOREIGN KEY (email, created) REFERENCES payments(email, created) ON DELETE CASCADE +); + INSERT INTO os_images (id, template_image_file_name, description) VALUES ('alpine311', 'alpine-cloud-2020-04-18.qcow2', 'Alpine Linux 3.11'), ('ubuntu18', 'ubuntu-18.04-minimal-cloudimg-amd64.img', 'Ubuntu 18.04 LTS (Bionic)'), diff --git a/capsulflask/static/style.css b/capsulflask/static/style.css index 4701265..df7c84c 100644 --- a/capsulflask/static/style.css +++ b/capsulflask/static/style.css @@ -226,6 +226,9 @@ th { td { border-bottom: 2px dotted #777e7355; } +.invalidated { + text-decoration: line-through; +} .waiting-pulse { animation: waiting-pulse 1s ease-in-out 0s infinite forwards alternate; diff --git a/capsulflask/templates/account-balance.html b/capsulflask/templates/account-balance.html index a7fccdd..dec3e2a 100644 --- a/capsulflask/templates/account-balance.html +++ b/capsulflask/templates/account-balance.html @@ -25,8 +25,8 @@ {% for payment in payments %} - ${{ payment["dollars"] }} - {{ payment["created"] }} + ${{ payment["dollars"] }} + {{ payment["created"] }} {% endfor %} @@ -38,10 +38,10 @@

PAYMENT OPTIONS

diff --git a/capsulflask/templates/btcpay.html b/capsulflask/templates/btcpay.html index 8f001ea..573c473 100644 --- a/capsulflask/templates/btcpay.html +++ b/capsulflask/templates/btcpay.html @@ -10,18 +10,21 @@
-
- - +
- --> +