btcpay working! added bitpay client code to source tree to fix a bug

fixed a stripe race condition

added account balance warning to account balance page
This commit is contained in:
forest 2020-05-15 18:18:19 -05:00
parent 67120e9461
commit e9dcf80f6c
17 changed files with 438 additions and 139 deletions

View File

@ -28,7 +28,6 @@ stripe = "*"
matplotlib = "*" matplotlib = "*"
requests = "*" requests = "*"
python-dotenv = "*" python-dotenv = "*"
bitpay = "*"
[dev-packages] [dev-packages]

16
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "51691e6158d1c309027d595bc4d26079c368f983223f7a567688dd5a98c865fa" "sha256": "4f61804f9405ff8f66e41739aeac24a2577f7b64f7d08b93459b568528c4f494"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -24,13 +24,6 @@
"index": "pypi", "index": "pypi",
"version": "==2.4.1" "version": "==2.4.1"
}, },
"bitpay": {
"hashes": [
"sha256:3da21326e5a7e98fae93f7fe4c1b046993cf1d8fba60a6c5049cc15f30026b75"
],
"index": "pypi",
"version": "==2.6.1910"
},
"blinker": { "blinker": {
"hashes": [ "hashes": [
"sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6" "sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6"
@ -67,13 +60,6 @@
], ],
"version": "==0.10.0" "version": "==0.10.0"
}, },
"ecdsa": {
"hashes": [
"sha256:867ec9cf6df0b03addc8ef66b56359643cb5d0c1dc329df76ba7ecfe256c8061",
"sha256:8f12ac317f8a1318efa75757ef0a651abe12e51fc1af8838fb91079445227277"
],
"version": "==0.15"
},
"flask": { "flask": {
"hashes": [ "hashes": [
"sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060", "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060",

View File

@ -105,7 +105,7 @@ In general, for safety, schema version upgrades should not delete data. Schema v
## 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 btcpay API client.
I used this code as an example: https://github.com/bitpay/bitpay-python/blob/master/bitpay/key_utils.py#L6 I used this code as an example: https://github.com/bitpay/bitpay-python/blob/master/bitpay/key_utils.py#L6

View File

@ -2,13 +2,13 @@
import os import os
import stripe 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
from flask import render_template from flask import render_template
from capsulflask import virt_model, cli from capsulflask import virt_model, cli
from capsulflask.btcpay import client as btcpay
load_dotenv(find_dotenv()) load_dotenv(find_dotenv())

View File

View File

@ -0,0 +1,121 @@
from capsulflask.btcpay.exceptions import *
from capsulflask.btcpay import key_utils
import requests
import json
import re
class Client:
def __init__(self, api_uri="https://bitpay.com", insecure=False, pem=key_utils.generate_pem(), tokens={}):
self.uri = api_uri
self.verify = not(insecure)
self.pem = pem
self.client_id = key_utils.get_sin_from_pem(pem)
self.tokens = tokens
self.user_agent = 'bitpay-python'
def pair_pos_client(self, code):
if re.match("^\w{7,7}$", code) is None:
raise BtcPayArgumentError("pairing code is not legal")
payload = {'id': self.client_id, 'pairingCode': code}
response = self.unsigned_request('/tokens', payload)
if response.ok:
self.tokens = self.token_from_response(response.json())
return self.tokens
self.response_error(response)
def create_token(self, facade):
payload = {'id': self.client_id, 'facade': facade}
response = self.unsigned_request('/tokens', payload)
if response.ok:
self.tokens = self.token_from_response(response.json())
return response.json()['data'][0]['pairingCode']
self.response_error(response)
def create_invoice(self, params):
self.verify_invoice_params(params['price'], params['currency'])
payload = json.dumps(params)
uri = self.uri + "/invoices"
xidentity = key_utils.get_compressed_public_key_from_pem(self.pem)
xsignature = key_utils.sign(uri + payload, self.pem)
headers = {"content-type": "application/json", 'X-Identity': xidentity, 'X-Signature': xsignature, 'X-accept-version': '2.0.0'}
try:
response = requests.post(uri, data=payload, headers=headers, verify=self.verify)
except Exception as pro:
raise BtcPayConnectionError(pro.args)
if response.ok:
return response.json()['data']
self.response_error(response)
def get_invoice(self, invoice_id):
uri = self.uri + "/invoices/" + invoice_id
xidentity = key_utils.get_compressed_public_key_from_pem(self.pem)
xsignature = key_utils.sign(uri, self.pem)
headers = {"content-type": "application/json", 'X-Identity': xidentity, 'X-Signature': xsignature, 'X-accept-version': '2.0.0'}
try:
response = requests.get(uri, headers=headers, verify=self.verify)
except Exception as pro:
raise BtcPayConnectionError(pro.args)
if response.ok:
return response.json()['data']
self.response_error(response)
def verify_tokens(self):
"""
Deprecated, will be made private in 2.4
"""
xidentity = key_utils.get_compressed_public_key_from_pem(self.pem)
url = self.uri + "/tokens"
xsignature = key_utils.sign(self.uri + "/tokens", self.pem)
headers = {"content-type": "application/json", 'X-Identity': xidentity, 'X-Signature': xsignature, 'X-accept-version': '2.0.0'}
response = requests.get(self.uri + "/tokens", headers=headers, verify=self.verify)
if response.ok:
allTokens = response.json()['data']
selfKeys = self.tokens.keys()
matchedTokens = [token for token in allTokens for key in selfKeys if token.get(key) == self.tokens.get(key)]
if not matchedTokens:
return False
return True
def token_from_response(self, responseJson):
"""
Deprecated, will be made private in 2.4
"""
token = responseJson['data'][0]['token']
facade = responseJson['data'][0]['facade']
return {facade: token}
raise BtcPayBtcPayError('%(code)d: %(message)s' % {'code': response.status_code, 'message': response.json()['error']})
def verify_invoice_params(self, price, currency):
"""
Deprecated, will be made private in 2.4
"""
if re.match("^[A-Z]{3,3}$", currency) is None:
raise BtcPayArgumentError("Currency is invalid.")
try:
float(price)
except:
raise BtcPayArgumentError("Price must be formatted as a float")
def response_error(self, response):
raise BtcPayBtcPayError('%(code)d: %(message)s' % {'code': response.status_code, 'message': response.json()['error']})
def unsigned_request(self, path, payload=None):
"""
generic btcpay usigned wrapper
passing a payload will do a POST, otherwise a GET
"""
headers = {"content-type": "application/json", "X-accept-version": "2.0.0"}
try:
if payload:
response = requests.post(self.uri + path, verify=self.verify, data=json.dumps(payload), headers=headers)
else:
response = requests.get(self.uri + path, verify=self.verify, headers=headers)
except Exception as pro:
raise BtcPayConnectionError('Connection refused')
return response
def unsigned_get_request(self, path, payload=None):
"""
Deprecated, will be removed in 2.4
"""
return self.unsigned_request('/tokens', payload)

View File

@ -0,0 +1,3 @@
class BtcPayArgumentError(Exception): pass
class BtcPayBtcPayError(Exception): pass
class BtcPayConnectionError(Exception): pass

View File

@ -0,0 +1,68 @@
from ecdsa import SigningKey, SECP256k1, VerifyingKey
from ecdsa import util as ecdsaUtil
import binascii
import hashlib
def generate_pem():
sk = SigningKey.generate(curve=SECP256k1)
pem = sk.to_pem()
pem = pem.decode("utf-8")
return pem
def get_sin_from_pem(pem):
public_key = get_compressed_public_key_from_pem(pem)
version = get_version_from_compressed_key(public_key)
checksum = get_checksum_from_version(version)
return base58encode(version + checksum)
def get_compressed_public_key_from_pem(pem):
vks = SigningKey.from_pem(pem).get_verifying_key().to_string()
bts = binascii.hexlify(vks)
compressed = compress_key(bts)
return compressed
def sign(message, pem):
message = message.encode()
sk = SigningKey.from_pem(pem)
signed = sk.sign(message, hashfunc=hashlib.sha256, sigencode=ecdsaUtil.sigencode_der)
return binascii.hexlify(signed).decode()
def base58encode(hexastring):
chars = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
int_val = int(hexastring, 16)
encoded = encode58("", int_val, chars)
return encoded
def encode58(string, int_val, chars):
if int_val == 0:
return string
else:
new_val, rem = divmod(int_val, 58)
new_string = (chars[rem]) + string
return encode58(new_string, new_val, chars)
def get_checksum_from_version(version):
return sha_digest(sha_digest(version))[0:8]
def get_version_from_compressed_key(key):
sh2 = sha_digest(key)
rphash = hashlib.new('ripemd160')
rphash.update(binascii.unhexlify(sh2))
rp1 = rphash.hexdigest()
return '0F02' + rp1
def sha_digest(hexastring):
return hashlib.sha256(binascii.unhexlify(hexastring)).hexdigest()
def compress_key(bts):
intval = int(bts, 16)
prefix = find_prefix(intval)
return prefix + bts[0:64].decode("utf-8")
def find_prefix(intval):
if(intval % 2 == 0):
prefix = '02'
else:
prefix = '03'
return prefix

View File

@ -1,7 +1,7 @@
import os import os
import re import re
from datetime import datetime from datetime import datetime, timedelta
import click import click
from flask.cli import with_appcontext from flask.cli import with_appcontext
@ -60,95 +60,158 @@ def sql_script(f, c):
model.connection.commit() model.connection.commit()
@bp.cli.command('cron-task') @bp.cli.command('cron-task')
@with_appcontext @with_appcontext
def cron_task(): def cron_task():
# make sure btcpay payments get completed (in case we miss a webhook), otherwise invalidate the payment # make sure btcpay payments get completed (in case we miss a webhook), otherwise invalidate the payment
print("cron_task: starting clean_up_unresolved_btcpay_invoices")
unresolved_btcpay_invoices = get_model().get_unresolved_btcpay_invoices() clean_up_unresolved_btcpay_invoices()
for invoice in unresolved_btcpay_invoices: print("cron_task: finished clean_up_unresolved_btcpay_invoices")
invoice_id = invoice.id
invoice = current_app.config['BTCPAY_CLIENT'].get_invoice(invoice_id)
days = float((datetime.now() - invoice.created).total_seconds())/float(60*60*24)
if invoice['status'] == "complete":
get_model().btcpay_invoice_resolved(invoice_id, True)
elif days >= 1:
get_model().btcpay_invoice_resolved(invoice_id, False)
# notify when funds run out # notify when funds run out
print("cron_task: starting notify_users_about_account_balance")
notify_users_about_account_balance()
print("cron_task: finished notify_users_about_account_balance")
accounts = get_model().accounts_list() # make sure vm system and DB are synced
print("cron_task: starting ensure_vms_and_db_are_synced")
ensure_vms_and_db_are_synced()
print("cron_task: finished ensure_vms_and_db_are_synced")
def clean_up_unresolved_btcpay_invoices():
unresolved_btcpay_invoices = get_model().get_unresolved_btcpay_invoices()
for unresolved_invoice in unresolved_btcpay_invoices:
invoice_id = unresolved_invoice['id']
btcpay_invoice = current_app.config['BTCPAY_CLIENT'].get_invoice(invoice_id)
days = float((datetime.now() - unresolved_invoice['created']).total_seconds())/float(60*60*24)
if btcpay_invoice['status'] == "complete":
print(
f"resolving btcpay invoice {invoice_id} "
f"({unresolved_invoice['email']}, ${unresolved_invoice['dollars']}) as completed "
)
get_model().btcpay_invoice_resolved(invoice_id, True)
elif days >= 1:
print(
f"resolving btcpay invoice {invoice_id} "
f"({unresolved_invoice['email']}, ${unresolved_invoice['dollars']}) as invalidated, "
f"btcpay server invoice status: {btcpay_invoice['status']}"
)
get_model().btcpay_invoice_resolved(invoice_id, False)
delete_at_account_balance_dollars = -10
def get_warning_headline(warning_id, pluralize_capsul):
return dict(
zero_1w= (
"According to our calculations, your Capsul account will run out of funds before this time next week.\n\n"
),
zero_1d= (
"According to our calculations, your Capsul account will run out of funds by this time tomorrow.\n\n"
),
zero_now= (
f"You have run out of funds! You will no longer be able to create Capsuls.\n\n"
f"As a courtesy, we'll let your existing Capsul{pluralize_capsul} keep running until your account "
"reaches a -$10 balance, at which point they will be deleted.\n\n"
),
delete_1w= (
"You have run out of funds and have not refilled your account.\n\n"
f"As a courtesy, we've let your existing Capsul{pluralize_capsul} keep running. "
f"However, your account will reach a -$10 balance some time next week and your Capsul{pluralize_capsul} "
"will be deleted.\n\n"
),
delete_1d= (
"You have run out of funds and have not refilled your account.\n\n"
f"As a courtesy, we have let your existing Capsul{pluralize_capsul} keep running. "
f"However, your account will reach a -$10 balance by this time tomorrow and "
f"your Capsul{pluralize_capsul} will be deleted.\n\n"
f"Last chance to deposit funds now and keep your Capsul{pluralize_capsul} running! "
),
delete_now= (
f"Your account reached a -$10 balance and your Capsul{pluralize_capsul} were deleted."
)
)[warning_id]
def get_warnings_list():
return [
dict(
id='zero_1w',
get_active=lambda balance_1w, balance_1d, balance_now: balance_1w < 0,
get_subject=lambda _: "Capsul One Week Payment Reminder",
get_body=lambda base_url, pluralize_capsul: (
f"{get_warning_headline('zero_1w', pluralize_capsul)}"
f"Log in now to re-fill your account! {base_url}/console/account-balance\n\n"
"If you believe you have recieved this message in error, please let us know: support@cyberia.club"
)
),
dict(
id='zero_1d',
get_active=lambda balance_1w, balance_1d, balance_now: balance_1d < 0,
get_subject=lambda _: "Capsul One Day Payment Reminder",
get_body=lambda base_url, pluralize_capsul: (
f"{get_warning_headline('zero_1d', pluralize_capsul)}"
f"Log in now to re-fill your account! {base_url}/console/account-balance\n\n"
"If you believe you have recieved this message in error, please let us know: support@cyberia.club"
)
),
dict(
id='zero_now',
get_active=lambda balance_1w, balance_1d, balance_now: balance_now < 0,
get_subject=lambda _: "Your Capsul Account is No Longer Funded",
get_body=lambda base_url, pluralize_capsul: (
f"{get_warning_headline('zero_now', pluralize_capsul)}"
f"Log in now to re-fill your account! {base_url}/console/account-balance\n\n"
f"If you need help decomissioning your Capsul{pluralize_capsul}, "
"would like to request backups, or de-activate your account, please contact: support@cyberia.club"
)
),
dict(
id='delete_1w',
get_active=lambda balance_1w, balance_1d, balance_now: balance_1w < delete_at_account_balance_dollars,
get_subject=lambda pluralize_capsul: f"Your Capsul{pluralize_capsul} Will be Deleted In Less Than a Week",
get_body=lambda base_url, pluralize_capsul: (
f"{get_warning_headline('delete_1w', pluralize_capsul)}"
f"Log in now to re-fill your account! {base_url}/console/account-balance\n\n"
f"If you need help decomissioning your Capsul{pluralize_capsul}, "
"would like to request backups, or de-activate your account, please contact: support@cyberia.club"
)
),
dict(
id='delete_1d',
get_active=lambda balance_1w, balance_1d, balance_now: balance_1d < delete_at_account_balance_dollars,
get_subject=lambda pluralize_capsul: f"Last Chance to Save your Capsul{pluralize_capsul}: Gone Tomorrow",
get_body=lambda base_url, pluralize_capsul: (
f"{get_warning_headline('delete_1d', pluralize_capsul)}"
f"{base_url}/console/account-balance"
)
),
dict(
id='delete_now',
get_active=lambda balance_1w, balance_1d, balance_now: balance_now < delete_at_account_balance_dollars,
get_subject=lambda pluralize_capsul: f"Capsul{pluralize_capsul} Deleted",
get_body=lambda base_url, pluralize_capsul: (
f"{get_warning_headline('delete_now', pluralize_capsul)}"
)
)
]
def notify_users_about_account_balance():
accounts = get_model().all_accounts()
for account in accounts: for account in accounts:
vms = get_model().list_vms_for_account(account['email']) vms = get_model().list_vms_for_account(account['email'])
payments = get_model().list_payments_for_account(account['email']) payments = get_model().list_payments_for_account(account['email'])
balance_1w = get_account_balance(vms, payments, datetime.utcnow() + datetime.timedelta(days=7)) balance_1w = get_account_balance(vms, payments, datetime.utcnow() + timedelta(days=7))
balance_1d = get_account_balance(vms, payments, datetime.utcnow() + datetime.timedelta(days=1)) balance_1d = get_account_balance(vms, payments, datetime.utcnow() + timedelta(days=1))
balance_now = get_account_balance(vms, payments, datetime.utcnow()) balance_now = get_account_balance(vms, payments, datetime.utcnow())
current_warning = account['account_balance_warning'] current_warning = account['account_balance_warning']
delete_at_account_balance = -10
pluralize_capsul = "s" if len(vms) > 1 else "" pluralize_capsul = "s" if len(vms) > 1 else ""
warnings = [ warnings = get_warnings_list()
dict(
id='zero_1w',
active=balance_1w < 0,
subject="Capsul One Week Payment Reminder",
body=("According to our calculations, your Capsul account will run out of funds in one week.\n\n"
f"Log in now to re-fill your account! {current_app.config['BASE_URL']}/console/account-balance\n\n"
"If you believe you have recieved this message in error, please let us know: support@cyberia.club")
),
dict(
id='zero_1d',
active=balance_1d < 0,
subject="Capsul One Day Payment Reminder",
body=("According to our calculations, your Capsul account will run out of funds tomorrow.\n\n"
f"Log in now to re-fill your account! {current_app.config['BASE_URL']}/console/account-balance\n\n"
"If you believe you have recieved this message in error, please let us know: support@cyberia.club")
),
dict(
id='zero_now',
active=balance_now < 0,
subject="Your Capsul Account is No Longer Funded",
body=(f"You have run out of funds! You will no longer be able to create Capsul{pluralize_capsul}.\n\n"
f"As a courtesy, we'll let your existing Capsul{pluralize_capsul} keep running until your account "
"reaches a -$10 balance, at which point they will be deleted.\n\n"
f"Log in now to re-fill your account! {current_app.config['BASE_URL']}/console/account-balance\n\n"
f"If you need help decomissioning your Capsul{pluralize_capsul}, "
"would like to request backups, or de-activate your account, please contact: support@cyberia.club")
),
dict(
id='delete_1w',
active=balance_1w < delete_at_account_balance,
subject=f"Your Capsul{pluralize_capsul} Will be Deleted In Less Than a Week",
body=("You have run out of funds and have not refilled your account.\n\n"
f"As a courtesy, we have let your existing Capsul{pluralize_capsul} keep running. "
f"However, your account will reach a -$10 balance some time next week and your Capsul{pluralize_capsul}"
"will be deleted.\n\n"
f"Log in now to re-fill your account! {current_app.config['BASE_URL']}/console/account-balance\n\n"
f"If you need help decomissioning your Capsul{pluralize_capsul}, "
"would like to request backups, or de-activate your account, please contact: support@cyberia.club")
),
dict(
id='delete_1d',
active=balance_1d < delete_at_account_balance,
subject=f"Last Chance to Save your Capsul{pluralize_capsul}: Gone Tomorrow",
body=("You have run out of funds and have not refilled your account.\n\n"
f"As a courtesy, we have let your existing Capsul{pluralize_capsul} keep running. "
f"However, your account will reach a -$10 balance tomorrow and your Capsul{pluralize_capsul} will be deleted.\n\n"
f"Last chance to deposit funds now and keep your Capsul{pluralize_capsul} running! {current_app.config['BASE_URL']}/console/account-balance")
),
dict(
id='delete_now',
active=balance_now < delete_at_account_balance,
subject=f"Capsul{pluralize_capsul} Deleted",
body=(f"Your account reached a -$10 balance and your Capsul{pluralize_capsul} were deleted.")
)
]
current_warning_index = -1 current_warning_index = -1
if current_warning: if current_warning:
for i in range(0, len(warnings)): for i in range(0, len(warnings)):
@ -157,15 +220,17 @@ def cron_task():
index_to_send = -1 index_to_send = -1
for i in range(0, len(warnings)): for i in range(0, len(warnings)):
if i > current_warning_index and warnings[i].active: if i > current_warning_index and warnings[i]['get_active'](balance_1w, balance_1d, balance_now):
index_to_send = i index_to_send = i
if index_to_send > -1: if index_to_send > -1:
print(f"cron_task: sending {warnings[index_to_send]['id']} warning email to {account['email']}.") print(f"cron_task: sending {warnings[index_to_send]['id']} warning email to {account['email']}.")
get_body = warnings[index_to_send]['get_body']
get_subject = warnings[index_to_send]['get_subject']
current_app.config["FLASK_MAIL_INSTANCE"].send( current_app.config["FLASK_MAIL_INSTANCE"].send(
Message( Message(
warnings[index_to_send]['subject'], get_subject(pluralize_capsul),
body=warnings[index_to_send]['body'], body=get_body(current_app.config['BASE_URL'], pluralize_capsul),
recipients=[account['email']] recipients=[account['email']]
) )
) )
@ -176,6 +241,5 @@ def cron_task():
current_app.config["VIRTUALIZATION_MODEL"].destroy(email=account["email"], id=vm['id']) current_app.config["VIRTUALIZATION_MODEL"].destroy(email=account["email"], id=vm['id'])
get_model().delete_vm(email=account["email"], id=vm['id']) get_model().delete_vm(email=account["email"], id=vm['id'])
def ensure_vms_and_db_are_synced():
print("a")
# make sure vm system and DB are synced

View File

@ -1,6 +1,6 @@
import re import re
import sys import sys
from datetime import datetime from datetime import datetime, timedelta
from flask import Blueprint from flask import Blueprint
from flask import flash from flask import flash
from flask import current_app from flask import current_app
@ -15,6 +15,7 @@ from nanoid import generate
from capsulflask.metrics import durations as metric_durations from capsulflask.metrics import durations as metric_durations
from capsulflask.auth import account_required from capsulflask.auth import account_required
from capsulflask.db import get_model, my_exec_info_message from capsulflask.db import get_model, my_exec_info_message
from capsulflask import cli
bp = Blueprint("console", __name__, url_prefix="/console") bp = Blueprint("console", __name__, url_prefix="/console")
@ -74,18 +75,18 @@ def detail(id):
return abort(404, f"{id} doesn't exist.") return abort(404, f"{id} doesn't exist.")
if vm['deleted']: if vm['deleted']:
return render_template("capsul-detail.html", vm=vm, delete=True, are_you_sure=True) return render_template("capsul-detail.html", vm=vm, delete=True, deleted=True)
if request.method == "POST": if request.method == "POST":
if 'are_you_sure' not in request.form or not request.form['are_you_sure']: if 'are_you_sure' not in request.form or not request.form['are_you_sure']:
return render_template("capsul-detail.html", vm=vm, delete=True, are_you_sure=False) return render_template("capsul-detail.html", vm=vm, delete=True, deleted=False)
else: else:
print(f"deleting {vm['id']} per user request ({session['account']})") print(f"deleting {vm['id']} per user request ({session['account']})")
current_app.config["VIRTUALIZATION_MODEL"].destroy(email=session['account'], id=id) current_app.config["VIRTUALIZATION_MODEL"].destroy(email=session['account'], id=id)
get_model().delete_vm(email=session['account'], id=id) get_model().delete_vm(email=session['account'], id=id)
return render_template("capsul-detail.html", vm=vm, delete=True, are_you_sure=True) return render_template("capsul-detail.html", vm=vm, delete=True, deleted=True)
else: else:
vm["ipv4"] = double_check_capsul_address(vm["id"], vm["ipv4"]) vm["ipv4"] = double_check_capsul_address(vm["id"], vm["ipv4"])
@ -277,7 +278,22 @@ def get_account_balance(vms, payments, as_of):
@account_required @account_required
def account_balance(): def account_balance():
payments = get_payments() payments = get_payments()
account_balance = get_account_balance(get_vms(), payments, datetime.utcnow()) vms = get_vms()
balance_1w = get_account_balance(vms, payments, datetime.utcnow() + timedelta(days=7))
balance_1d = get_account_balance(vms, payments, datetime.utcnow() + timedelta(days=1))
balance_now = get_account_balance(vms, payments, datetime.utcnow())
warning_index = -1
warning_text = ""
warnings = cli.get_warnings_list()
for i in range(0, len(warnings)):
if warnings[i]['get_active'](balance_1w, balance_1d, balance_now):
warning_index = i
if warning_index > -1:
pluralize_capsul = "s" if len(vms) > 1 else ""
warning_id = warnings[warning_index]['id']
warning_text = cli.get_warning_headline(warning_id, pluralize_capsul)
vms_billed = list() vms_billed = list()
@ -299,6 +315,7 @@ 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,
warning_text=warning_text,
payments=list(map( payments=list(map(
lambda x: dict( lambda x: dict(
dollars=x["dollars"], dollars=x["dollars"],
@ -308,5 +325,5 @@ def account_balance():
payments payments
)), )),
has_payments=len(payments)>0, has_payments=len(payments)>0,
account_balance=format(account_balance, '.2f') account_balance=format(balance_now, '.2f')
) )

View File

@ -203,8 +203,11 @@ class DBModel:
def get_unresolved_btcpay_invoices(self): def get_unresolved_btcpay_invoices(self):
self.cursor.execute("SELECT id, payments.created, email FROM unresolved_btcpay_invoices JOIN payments on payment_id = payment.id") self.cursor.execute("""
return list(map(lambda row: dict(id=row[0], created=row[1], email=row[2]), self.cursor.fetchall())) SELECT unresolved_btcpay_invoices.id, payments.created, payments.dollars, unresolved_btcpay_invoices.email
FROM unresolved_btcpay_invoices JOIN payments on payment_id = payments.id
""")
return list(map(lambda row: dict(id=row[0], created=row[1], dollars=row[2], email=row[3]), self.cursor.fetchall()))
def get_account_balance_warning(self, email): def get_account_balance_warning(self, email):
self.cursor.execute("SELECT account_balance_warning FROM accounts WHERE email = %s", (email,)) self.cursor.execute("SELECT account_balance_warning FROM accounts WHERE email = %s", (email,))
@ -216,7 +219,7 @@ class DBModel:
def all_accounts(self): def all_accounts(self):
self.cursor.execute("SELECT email, account_balance_warning FROM accounts") self.cursor.execute("SELECT email, account_balance_warning FROM accounts")
return list(map(lambda row: dict(row=row[0], account_balance_warning=row[1]), self.cursor.fetchall())) return list(map(lambda row: dict(email=row[0], account_balance_warning=row[1]), self.cursor.fetchall()))

View File

@ -2,6 +2,7 @@ import stripe
import json import json
import time import time
import decimal import decimal
from time import sleep
from flask import Blueprint from flask import Blueprint
from flask import request from flask import request
@ -55,7 +56,7 @@ def btcpay_payment():
currency="USD", currency="USD",
itemDesc="Capsul Cloud Compute", itemDesc="Capsul Cloud Compute",
transactionSpeed="high", transactionSpeed="high",
redirectURL=f"{current_app.config['BASE_URL']}/account-balance", redirectURL=f"{current_app.config['BASE_URL']}/console/account-balance",
notificationURL=f"{current_app.config['BASE_URL']}/payment/btcpay/webhook" notificationURL=f"{current_app.config['BASE_URL']}/payment/btcpay/webhook"
)) ))
# print(invoice) # print(invoice)
@ -89,16 +90,18 @@ def btcpay_webhook():
dollars = invoice['price'] dollars = invoice['price']
if invoice['status'] == "paid" or invoice['status'] == "confirmed": if invoice['status'] == "paid" or invoice['status'] == "confirmed" or invoice['status'] == "complete":
success_account = get_model().consume_payment_session("btcpay", invoice_id, dollars) success_account = get_model().consume_payment_session("btcpay", invoice_id, dollars)
if success_account: if success_account:
print(f"{success_account} paid ${dollars} successfully (btcpay_invoice_id={invoice_id})") print(f"{success_account} paid ${dollars} successfully (btcpay_invoice_id={invoice_id})")
elif invoice['status'] == "complete": if invoice['status'] == "complete":
get_model().btcpay_invoice_resolved(invoice_id, True) get_model().btcpay_invoice_resolved(invoice_id, True)
elif invoice['status'] == "expired" or invoice['status'] == "invalid": elif invoice['status'] == "expired" or invoice['status'] == "invalid":
get_model().btcpay_invoice_resolved(invoice_id, False) get_model().btcpay_invoice_resolved(invoice_id, False)
return {"msg": "ok"}, 200
@ -122,7 +125,7 @@ def stripe_payment():
success_url=current_app.config['BASE_URL'] + "/payment/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'] + "/payment/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=[
{ {
"name": "Capsul Cloud Compute", "name": "Capsul Cloud Compute",
@ -139,6 +142,17 @@ def stripe_payment():
get_model().create_payment_session("stripe", stripe_checkout_session_id, session["account"], dollars) get_model().create_payment_session("stripe", stripe_checkout_session_id, session["account"], dollars)
# 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}")
for error in errors: for error in errors:
flash(error) flash(error)
@ -148,6 +162,31 @@ def stripe_payment():
stripe_public_key=current_app.config["STRIPE_PUBLISHABLE_KEY"] stripe_public_key=current_app.config["STRIPE_PUBLISHABLE_KEY"]
) )
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['display_items'][0]['amount']
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)
if success_email:
return dict(email=success_email, dollars=dollars)
return None
@bp.route("/stripe/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')
@ -155,29 +194,14 @@ def success():
print("/payment/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( for _ in range(0, 5):
type='checkout.session.completed', paid = validate_stripe_checkout_session(stripe_checkout_session_id)
created={ if paid:
# Check for events created in the last half hour print(f"{paid['email']} paid ${paid['dollars']} successfully (stripe_checkout_session_id={stripe_checkout_session_id})")
'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['display_items'][0]['amount']
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_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})")
return redirect(url_for("console.account_balance")) return redirect(url_for("console.account_balance"))
else:
sleep(1)
abort(400, "this checkout session is not paid yet") abort(400, "this checkout session is not paid yet")

View File

@ -103,9 +103,9 @@ INSERT INTO accounts (email)
VALUES ('forest.n.johnson@gmail.com'); VALUES ('forest.n.johnson@gmail.com');
INSERT INTO payments (email, dollars, created) INSERT INTO payments (email, dollars, created)
VALUES ('forest.n.johnson@gmail.com', 20.00, TO_TIMESTAMP('2020-05-05','YYYY-MM-DDTHH24-MI-SS')); VALUES ('forest.n.johnson@gmail.com', 20.00, TO_TIMESTAMP('2020-04-05','YYYY-MM-DD'));
INSERT INTO vms (id, email, os, size) INSERT INTO vms (id, email, os, size, created)
VALUES ('capsul-yi9ffqbjly', 'forest.n.johnson@gmail.com', 'alpine311', 'f1-xx'); VALUES ('capsul-yi9ffqbjly', 'forest.n.johnson@gmail.com', 'alpine311', 'f1-xx', TO_TIMESTAMP('2020-04-19','YYYY-MM-DD'));
UPDATE schemaversion SET version = 2; UPDATE schemaversion SET version = 2;

View File

@ -116,6 +116,10 @@ form {
margin: 0; margin: 0;
} }
pre.wrap {
white-space: normal;
}
label.align { label.align {
min-width: 10em; min-width: 10em;
} }

View File

@ -8,6 +8,11 @@
</div> </div>
<div class="half-margin"> <div class="half-margin">
{% if has_vms and has_payments and warning_text != "" %}
<div class="row">
<pre class="wrap">{{ warning_text }}</pre>
</div>
{% endif %}
<div class="row"> <div class="row">
{% if has_payments %} {% if has_payments %}
<div> <div>
@ -43,7 +48,7 @@
</li> </li>
<li><a href="/payment/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 <a href="mailto:treasurer@cyberia.club">treasurer@cyberia.club</a></li>
</ul> </ul>
</li> </li>
</ul> </ul>

View File

@ -5,7 +5,7 @@
{% block content %} {% block content %}
{% if delete %} {% if delete %}
{% if are_you_sure %} {% if deleted %}
<div class="row third-margin"> <div class="row third-margin">
<h1>DELETED</h1> <h1>DELETED</h1>
</div> </div>

View File

@ -2,7 +2,12 @@
{% block title %}Pay with Stripe{% endblock %} {% block title %}Pay with Stripe{% endblock %}
{% block head %}<script src="https://js.stripe.com/v3/"></script>{% endblock %} {% block head %}
{% if stripe_checkout_session_id %}
<script src="https://js.stripe.com/v3/"></script>
{% endif %}
{% endblock %}
{% block content %} {% block content %}
<div class="row third-margin"> <div class="row third-margin">