btcpay generating invoices and payments can be invalidated
This commit is contained in:
parent
6440961433
commit
8de802aff5
@ -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.
|
||||
|
||||
|
@ -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")
|
||||
|
@ -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')
|
||||
)
|
@ -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]
|
||||
|
@ -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":
|
||||
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")
|
||||
|
@ -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")
|
@ -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)'),
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user