btcpay generating invoices and payments can be invalidated

This commit is contained in:
forest 2020-05-14 18:03:00 -05:00
parent 6440961433
commit 8de802aff5
10 changed files with 131 additions and 71 deletions

View File

@ -50,7 +50,8 @@ Run the app in gunicorn
.venv/bin/gunicorn --bind 127.0.0.1:5000 capsulflask:app .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 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. `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. 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. Generate a private key and the accompanying bitpay SIN for the bitpay API client.

View File

@ -1,6 +1,8 @@
import stripe
import os import os
import stripe
from bitpay import client as btcpay
from dotenv import load_dotenv, find_dotenv from dotenv import load_dotenv, find_dotenv
from flask import Flask from flask import Flask
from flask_mail import Mail from flask_mail import Mail
@ -31,7 +33,8 @@ app.config.from_mapping(
STRIPE_PUBLISHABLE_KEY=os.environ.get("STRIPE_PUBLISHABLE_KEY", default=""), STRIPE_PUBLISHABLE_KEY=os.environ.get("STRIPE_PUBLISHABLE_KEY", default=""),
#STRIPE_WEBHOOK_SECRET=os.environ.get("STRIPE_WEBHOOK_SECRET", 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'] 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['FLASK_MAIL_INSTANCE'] = Mail(app)
app.config['VIRTUALIZATION_MODEL'] = virt_model.MockVirtualization() 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 from capsulflask import db
db.init_app(app) 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(landing.bp)
app.register_blueprint(auth.bp) app.register_blueprint(auth.bp)
app.register_blueprint(console.bp) app.register_blueprint(console.bp)
app.register_blueprint(payment_stripe.bp) app.register_blueprint(payment.bp)
app.register_blueprint(payment_btcpay.bp)
app.register_blueprint(metrics.bp) app.register_blueprint(metrics.bp)
app.add_url_rule("/", endpoint="index") app.add_url_rule("/", endpoint="index")

View File

@ -254,7 +254,7 @@ def get_account_balance():
vm_months = ( end_datetime - vm["created"] ).days / average_number_of_days_in_a_month vm_months = ( end_datetime - vm["created"] ).days / average_number_of_days_in_a_month
vm_cost_dollars += vm_months * float(vm["dollars_per_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 return payment_dollars_total - vm_cost_dollars
@ -268,8 +268,8 @@ def account_balance():
for vm in get_vms(): for vm in get_vms():
end_datetime = vm["deleted"] if vm["deleted"] else datetime.utcnow() end_datetime = vm["deleted"] if vm["deleted"] else datetime.utcnow()
print(end_datetime) # print(end_datetime)
print(vm["created"]) # print(vm["created"])
vm_months = (end_datetime - vm["created"]).days / average_number_of_days_in_a_month vm_months = (end_datetime - vm["created"]).days / average_number_of_days_in_a_month
vms_billed.append(dict( vms_billed.append(dict(
id=vm["id"], id=vm["id"],
@ -284,7 +284,14 @@ def account_balance():
"account-balance.html", "account-balance.html",
has_vms=len(vms_billed)>0, has_vms=len(vms_billed)>0,
vms_billed=vms_billed, 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, has_payments=len(payments)>0,
account_balance=format(account_balance, '.2f') account_balance=format(account_balance, '.2f')
) )

View File

@ -146,38 +146,38 @@ class DBModel:
def list_payments_for_account(self, email): def list_payments_for_account(self, email):
self.cursor.execute(""" self.cursor.execute("""
SELECT payments.dollars, payments.created SELECT dollars, invalidated, created
FROM payments WHERE payments.email = %s""", FROM payments WHERE payments.email = %s""",
(email, ) (email, )
) )
return list(map( 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() 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(""" self.cursor.execute("""
INSERT INTO stripe_checkout_sessions (id, email, dollars) INSERT INTO payment_sessions (id, type, email, dollars)
VALUES (%s, %s, %s) VALUES (%s, %s, %s, %s)
""", """,
(id, email, dollars) (id, payment_type, email, dollars)
) )
self.connection.commit() self.connection.commit()
def consume_stripe_checkout_session(self, id, dollars): def consume_payment_session(self, payment_type, id, dollars):
self.cursor.execute("SELECT email, dollars FROM stripe_checkout_sessions WHERE id = %s", (id,)) self.cursor.execute("SELECT email, dollars FROM payment_sessions WHERE id = %s AND type = %s", (id, payment_type))
rows = self.cursor.fetchall() rows = self.cursor.fetchall()
if len(rows) > 0: if len(rows) > 0:
if int(rows[0][1]) != int(dollars): if int(rows[0][1]) != int(dollars):
print(f""" print(f"""
Stripe sent us a completed checkout session with a different dollar amount than what we had recorded!! {payment_type} gave us a completed payment session with a different dollar amount than what we had recorded!!
stripe_checkout_session_id: {id} id: {id}
account: {rows[0][0]} account: {rows[0][0]}
Recorded amount: {int(rows[0][1])} 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. # 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.cursor.execute( "INSERT INTO payments (email, dollars) VALUES (%s, %s)", (rows[0][0], rows[0][1]) )
self.connection.commit() self.connection.commit()
return rows[0][0] return rows[0][0]

View File

@ -18,34 +18,79 @@ from capsulflask.auth import account_required
from capsulflask.db import get_model 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")) def validate_dollars():
@account_required
def index():
stripe_checkout_session_id=None
errors = list() errors = list()
dollars = None
if request.method == "POST": if "dollars" not in request.form:
if "dollars" not in request.form: errors.append("dollars is required")
errors.append("dollars is required") else:
dollars = None dollars = None
try: try:
dollars = decimal.Decimal(request.form["dollars"]) dollars = decimal.Decimal(request.form["dollars"])
except: except:
errors.append("dollars must be a number") errors.append("dollars must be a number")
if dollars and dollars < decimal.Decimal(1): # TODO re enable this
errors.append("dollars must be >= 1") # 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: 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( checkout_session = stripe.checkout.Session.create(
success_url=current_app.config['BASE_URL'] + "/stripe/success?session_id={CHECKOUT_SESSION_ID}", success_url=current_app.config['BASE_URL'] + "/payment/stripe/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url=current_app.config['BASE_URL'] + "/stripe", cancel_url=current_app.config['BASE_URL'] + "/payment/stripe",
payment_method_types=["card"], payment_method_types=["card"],
customer_email=session["account"], customer_email=session["account"],
line_items=[ line_items=[
@ -60,9 +105,9 @@ def index():
) )
stripe_checkout_session_id = checkout_session['id'] 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: for error in errors:
flash(error) flash(error)
@ -73,11 +118,11 @@ def index():
stripe_public_key=current_app.config["STRIPE_PUBLISHABLE_KEY"] stripe_public_key=current_app.config["STRIPE_PUBLISHABLE_KEY"]
) )
@bp.route("/success", methods=("GET",)) @bp.route("/stripe/success", methods=("GET",))
def success(): def success():
stripe_checkout_session_id = request.args.get('session_id') stripe_checkout_session_id = request.args.get('session_id')
if not stripe_checkout_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") abort(400, "missing required URL parameter session_id")
else: else:
checkout_session_completed_events = stripe.Event.list( checkout_session_completed_events = stripe.Event.list(
@ -95,9 +140,9 @@ def success():
cents = checkout_session['display_items'][0]['amount'] cents = checkout_session['display_items'][0]['amount']
dollars = decimal.Decimal(cents)/100 dollars = decimal.Decimal(cents)/100
#consume_stripe_checkout_session deletes the checkout session row and inserts a payment row #consume_payment_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 # 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_stripe_checkout_session(stripe_checkout_session_id, dollars) success_account = get_model().consume_payment_session(stripe_checkout_session_id, dollars)
if success_account: if success_account:
print(f"{success_account} paid ${dollars} successfully (stripe_checkout_session_id={stripe_checkout_session_id})") 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'] # dollars = event['data']['object']['display_items'][0]['amount']
# stripe_checkout_session_id = event['data']['object']['id'] # stripe_checkout_session_id = event['data']['object']['id']
# #consume_stripe_checkout_session deletes the checkout session row and inserts a payment row # #consume_payment_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 # # 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_stripe_checkout_session(stripe_checkout_session_id, dollars) # success_account = get_model().consume_payment_session(stripe_checkout_session_id, dollars)
# if success_account: # if success_account:
# print(f"{success_account} paid ${dollars} successfully (stripe_checkout_session_id={stripe_checkout_session_id})") # print(f"{success_account} paid ${dollars} successfully (stripe_checkout_session_id={stripe_checkout_session_id})")
# return jsonify({'status': 'success'}) # return jsonify({'status': 'success'})
# except ValueError as e: # 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") # abort(400, "bad request")
# except stripe.error.SignatureVerificationError: # except stripe.error.SignatureVerificationError:
# print("/stripe/webhook returned 400: invalid signature") # print("/payment/stripe/webhook returned 400: invalid signature")
# abort(400, "invalid signature") # abort(400, "invalid signature")

View File

@ -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")

View File

@ -52,6 +52,7 @@ CREATE TABLE payments (
email TEXT REFERENCES accounts(email) ON DELETE RESTRICT, email TEXT REFERENCES accounts(email) ON DELETE RESTRICT,
created TIMESTAMP NOT NULL DEFAULT NOW(), created TIMESTAMP NOT NULL DEFAULT NOW(),
dollars NUMERIC(8, 2) NOT NULL, dollars NUMERIC(8, 2) NOT NULL,
invalidated BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (email, created) PRIMARY KEY (email, created)
); );
@ -62,13 +63,21 @@ CREATE TABLE login_tokens (
PRIMARY KEY (email, created) PRIMARY KEY (email, created)
); );
CREATE TABLE stripe_checkout_sessions ( CREATE TABLE payment_sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
email TEXT REFERENCES accounts(email) ON DELETE RESTRICT, email TEXT REFERENCES accounts(email) ON DELETE RESTRICT,
type TEXT NOT NULL,
created TIMESTAMP NOT NULL DEFAULT NOW(), created TIMESTAMP NOT NULL DEFAULT NOW(),
dollars NUMERIC(8, 2) NOT NULL 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) INSERT INTO os_images (id, template_image_file_name, description)
VALUES ('alpine311', 'alpine-cloud-2020-04-18.qcow2', 'Alpine Linux 3.11'), 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)'), ('ubuntu18', 'ubuntu-18.04-minimal-cloudimg-amd64.img', 'Ubuntu 18.04 LTS (Bionic)'),

View File

@ -226,6 +226,9 @@ th {
td { td {
border-bottom: 2px dotted #777e7355; border-bottom: 2px dotted #777e7355;
} }
.invalidated {
text-decoration: line-through;
}
.waiting-pulse { .waiting-pulse {
animation: waiting-pulse 1s ease-in-out 0s infinite forwards alternate; animation: waiting-pulse 1s ease-in-out 0s infinite forwards alternate;

View File

@ -25,8 +25,8 @@
<tbody> <tbody>
{% for payment in payments %} {% for payment in payments %}
<tr> <tr>
<td>${{ payment["dollars"] }}</td> <td class="{{ payment['class_name'] }}">${{ payment["dollars"] }}</td>
<td>{{ payment["created"] }}</td> <td class="{{ payment['class_name'] }}">{{ payment["created"] }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -38,10 +38,10 @@
<h1>PAYMENT OPTIONS</h1> <h1>PAYMENT OPTIONS</h1>
<ul> <ul>
<li> <li>
<a href="/stripe">Add funds with Credit/Debit (stripe)</a> <a href="/payment/stripe">Add funds with Credit/Debit (stripe)</a>
<ul><li>notice: stripe will load nonfree javascript </li></ul> <ul><li>notice: stripe will load nonfree javascript </li></ul>
</li> </li>
<li><a href="/btcpay">Add funds with Bitcoin/Litecoin/Monero (btcpay)</a></li> <li><a href="/payment/btcpay">Add funds with Bitcoin/Litecoin/Monero (btcpay)</a></li>
<li>Cash: email treasurer@cyberia.club</li> <li>Cash: email treasurer@cyberia.club</li>
</ul> </ul>

View File

@ -10,18 +10,21 @@
</div> </div>
<div class="row half-margin"> <div class="row half-margin">
<form method="POST" action="https://btcpay.cyberia.club/api/v1/invoices"> <form method="POST" action="/payment/btcpay">
<input type="hidden" name="storeId" value="FgYNGKEHKm2tBhwejo1zdSQ15DknPWvip2pXLKBv96wc">
<input type="hidden" name="currency" value="USD">
<div class="row"> <div class="row">
<label for="btcpay-input-price">$</label> <label for="btcpay-input-price">$</label>
<input <!--TODO-- <input
id="btcpay-input-price" id="btcpay-input-price"
name="price" name="price"
type="number" type="number"
min="0" min="0"
max="2000" max="2000"
oninput="event.preventDefault();isNaN(event.target.value) || event.target.value <= 0 ? document.querySelector('#btcpay-input-price').value = 0 : event.target.value" oninput="event.preventDefault();isNaN(event.target.value) || event.target.value <= 0 ? document.querySelector('#btcpay-input-price').value = 0 : event.target.value"
/>-->
<input
id="btcpay-input-price"
name="dollars"
type="text"
/> />
<input type="image" class="submit" name="submit" <input type="image" class="submit" name="submit"
src="https://btcpay.cyberia.club/img/paybutton/pay.svg" src="https://btcpay.cyberia.club/img/paybutton/pay.svg"