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
```
## 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.

View File

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

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_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')
)

View File

@ -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]

View File

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

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,
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)'),

View File

@ -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;

View File

@ -25,8 +25,8 @@
<tbody>
{% for payment in payments %}
<tr>
<td>${{ payment["dollars"] }}</td>
<td>{{ payment["created"] }}</td>
<td class="{{ payment['class_name'] }}">${{ payment["dollars"] }}</td>
<td class="{{ payment['class_name'] }}">{{ payment["created"] }}</td>
</tr>
{% endfor %}
</tbody>
@ -38,10 +38,10 @@
<h1>PAYMENT OPTIONS</h1>
<ul>
<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>
</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>
</ul>

View File

@ -10,18 +10,21 @@
</div>
<div class="row half-margin">
<form method="POST" action="https://btcpay.cyberia.club/api/v1/invoices">
<input type="hidden" name="storeId" value="FgYNGKEHKm2tBhwejo1zdSQ15DknPWvip2pXLKBv96wc">
<input type="hidden" name="currency" value="USD">
<form method="POST" action="/payment/btcpay">
<div class="row">
<label for="btcpay-input-price">$</label>
<input
<!--TODO-- <input
id="btcpay-input-price"
name="price"
type="number"
min="0"
max="2000"
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"
src="https://btcpay.cyberia.club/img/paybutton/pay.svg"