diff --git a/capsulflask/cli.py b/capsulflask/cli.py index 537d488..d2ce609 100644 --- a/capsulflask/cli.py +++ b/capsulflask/cli.py @@ -6,9 +6,12 @@ from datetime import datetime import click from flask.cli import with_appcontext from flask import Blueprint +from flask import current_app from psycopg2 import ProgrammingError +from flask_mail import Message from capsulflask.db import get_model, my_exec_info_message +from capsulflask.console import get_account_balance bp = Blueprint('cli', __name__) @@ -44,7 +47,7 @@ def sql_script(f, c): for row in model.cursor.fetchall(): def format_value(x): if isinstance(x, bool): - return "TRUE" if x else "FALSE" + return "true" if x else "false" if not x : return "null" if isinstance(x, datetime): @@ -60,4 +63,115 @@ def sql_script(f, c): @bp.cli.command('cron-task') @with_appcontext def cron_task(): - print('a') \ No newline at end of file + + # make sure btcpay payments get completed (in case we miss a webhook), otherwise invalidate the payment + + unresolved_btcpay_invoices = get_model().get_unresolved_btcpay_invoices() + for invoice in 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 + + accounts = get_model().accounts_list() + for account in accounts: + vms = get_model().list_vms_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_1d = get_account_balance(vms, payments, datetime.utcnow() + datetime.timedelta(days=1)) + balance_now = get_account_balance(vms, payments, datetime.utcnow()) + current_warning = account['account_balance_warning'] + + delete_at_account_balance = -10 + + pluralize_capsul = "s" if len(vms) > 1 else "" + + warnings = [ + 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 + if current_warning: + for i in range(0, len(warnings)): + if warnings[i]['id'] == current_warning: + current_warning_index = i + + index_to_send = -1 + for i in range(0, len(warnings)): + if i > current_warning_index and warnings[i].active: + index_to_send = i + + if index_to_send > -1: + current_app.config["FLASK_MAIL_INSTANCE"].send( + Message( + warnings[index_to_send]['subject'], + body=warnings[index_to_send]['body'], + recipients=[account['email']] + ) + ) + get_model().set_account_balance_warning(account['email'], warnings[index_to_send]['id']) + if index_to_send == len(warnings)-1: + print('TODO: delete capsuls') + + + + # make sure vm system and DB are synced diff --git a/capsulflask/console.py b/capsulflask/console.py index 12f6005..92717f5 100644 --- a/capsulflask/console.py +++ b/capsulflask/console.py @@ -91,7 +91,7 @@ def create(): vm_sizes = get_model().vm_sizes_dict() operating_systems = get_model().operating_systems_dict() ssh_public_keys = get_model().list_ssh_public_keys_for_account(session["account"]) - account_balance = get_account_balance() + account_balance = get_account_balance(get_vms(), get_payments(), datetime.utcnow()) capacity_avaliable = current_app.config["VIRTUALIZATION_MODEL"].capacity_avaliable(512*1024*1024) errors = list() created_os = None @@ -246,15 +246,15 @@ def get_payments(): average_number_of_days_in_a_month = 30.44 -def get_account_balance(): +def get_account_balance(vms, payments, as_of): vm_cost_dollars = 0.0 - for vm in get_vms(): - end_datetime = vm["deleted"] if vm["deleted"] else datetime.utcnow() + for vm in vms: + end_datetime = vm["deleted"] if vm["deleted"] else as_of 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: 0 if x["invalidated"] else x["dollars"], get_payments())) ) + payment_dollars_total = float( sum(map(lambda x: 0 if x["invalidated"] else x["dollars"], payments)) ) return payment_dollars_total - vm_cost_dollars @@ -262,7 +262,7 @@ def get_account_balance(): @account_required def account_balance(): payments = get_payments() - account_balance = get_account_balance() + account_balance = get_account_balance(get_vms(), payments, datetime.utcnow()) vms_billed = list() diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py index b6ceafd..533c3be 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -24,9 +24,9 @@ class DBModel: def consume_token(self, token): self.cursor.execute("SELECT email FROM login_tokens WHERE token = %s and created > (NOW() - INTERVAL '20 min')", (token, )) - rows = self.cursor.fetchall() - if len(rows) > 0: - email = rows[0][0] + row = self.cursor.fetchone() + if row: + email = row[0] self.cursor.execute("DELETE FROM login_tokens WHERE email = %s", (email, )) self.connection.commit() return email @@ -125,14 +125,13 @@ class DBModel: WHERE vms.email = %s AND vms.id = %s""", (email, id) ) - rows = self.cursor.fetchall() - if len(rows) == 0: + row = self.cursor.fetchone() + if not row: return None - x = rows[0] vm = dict( - id=x[0], ipv4=x[1], ipv6=x[2], os_description=x[3], created=x[4], deleted=x[5], - size=x[6], dollars_per_month=x[7], vcpus=x[8], memory_mb=x[9], bandwidth_gb_per_month=x[10] + id=row[0], ipv4=row[1], ipv6=row[2], os_description=row[3], created=row[4], deleted=row[5], + size=row[6], dollars_per_month=row[7], vcpus=row[8], memory_mb=row[9], bandwidth_gb_per_month=row[10] ) self.cursor.execute(""" @@ -166,23 +165,62 @@ class DBModel: 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): + row = self.cursor.fetchone() + if row: + if int(row[1]) != int(dollars): print(f""" {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])} + account: {row[0]} + Recorded amount: {int(row[1])} {payment_type} sent: {int(dollars)} """) # not sure what to do here. For now just log and do nothing. 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) RETURNING id", (row[0], row[1]) ) + + if payment_type == "btcpay": + payment_id = self.cursor.fetchone()[0] + self.cursor.execute( + "INSERT INTO unresolved_btcpay_invoices (id, email, payment_id) VALUES (%s, %s, %s)", + (id, row[0], payment_id) + ) + self.connection.commit() - return rows[0][0] + return row[0] else: return None + def btcpay_invoice_resolved(self, id, completed): + self.cursor.execute("SELECT email, payment_id FROM unresolved_btcpay_invoices WHERE id = %s ", (id,)) + row = self.cursor.fetchone() + if row: + self.cursor.execute( "DELETE FROM unresolved_btcpay_invoices WHERE id = %s", (id,) ) + if not completed: + self.cursor.execute("UPDATE payments SET invalidated = True WHERE email = %s id = %s", (row[0], row[1])) + + self.connection.commit() + + + 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") + return list(map(lambda row: dict(id=row[0], created=row[1], email=row[2]), self.cursor.fetchall())) + + def get_account_balance_warning(self, email): + self.cursor.execute("SELECT account_balance_warning FROM accounts WHERE email = %s", (email,)) + return self.cursor.fetchone()[0] + + def set_account_balance_warning(self, email, account_balance_warning): + self.cursor.execute("UPDATE accounts SET account_balance_warning = %s WHERE email = %s", (account_balance_warning, email)) + self.connection.commit() + + def all_accounts(self): + 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())) + + + + + diff --git a/capsulflask/payment.py b/capsulflask/payment.py index b76332e..90313ce 100644 --- a/capsulflask/payment.py +++ b/capsulflask/payment.py @@ -55,12 +55,13 @@ def btcpay_payment(): currency="USD", itemDesc="Capsul Cloud Compute", transactionSpeed="high", - redirectURL=f"{current_app.config['BASE_URL']}/account-balance" + redirectURL=f"{current_app.config['BASE_URL']}/account-balance", + notificationURL=f"{current_app.config['BASE_URL']}/payment/btcpay/webhook" )) # print(invoice) invoice_id = invoice["id"] - print(f"created btcpay invoice_id={invoice_id} ( {session['account']}, ${request.form['dollars']} )") + print(f"created btcpay invoice_id={invoice_id} ( {session['account']}, ${dollars} )") get_model().create_payment_session("btcpay", invoice_id, session["account"], dollars) @@ -72,6 +73,35 @@ def btcpay_payment(): return render_template("btcpay.html", invoice_id=invoice_id) +@bp.route("/btcpay/webhook", methods=("POST",)) +def btcpay_webhook(): + + # IMPORTANT! there is no signature or credential for the data sent into this webhook :facepalm: + # its just a notification, thats all. + request_data = json.loads(request.data) + invoice_id = request_data['id'] + + # so you better make sure to get the invoice directly from the horses mouth! + invoice = current_app.config['BTCPAY_CLIENT'].get_invoice(invoice_id) + + if invoice['currency'] != "USD": + abort(400, "invalid currency") + + dollars = invoice['price'] + + if invoice['status'] == "paid" or invoice['status'] == "confirmed": + success_account = get_model().consume_payment_session("btcpay", invoice_id, dollars) + + if success_account: + print(f"{success_account} paid ${dollars} successfully (btcpay_invoice_id={invoice_id})") + + elif invoice['status'] == "complete": + get_model().btcpay_invoice_resolved(invoice_id, True) + elif invoice['status'] == "expired" or invoice['status'] == "invalid": + get_model().btcpay_invoice_resolved(invoice_id, False) + + + @bp.route("/stripe", methods=("GET", "POST")) @account_required def stripe_payment(): @@ -142,7 +172,7 @@ def success(): #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) + 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})") @@ -168,7 +198,7 @@ def success(): # #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) +# 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})") diff --git a/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql b/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql index 1b28535..a434fb7 100644 --- a/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql +++ b/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql @@ -2,6 +2,7 @@ CREATE TABLE accounts ( email TEXT PRIMARY KEY NOT NULL, + account_balance_warning TEXT NULL, created TIMESTAMP NOT NULL DEFAULT NOW() ); @@ -72,13 +73,14 @@ CREATE TABLE payment_sessions ( dollars NUMERIC(8, 2) NOT NULL ); -CREATE TABLE unconfirmed_btcpay_invoices ( +CREATE TABLE unresolved_btcpay_invoices ( id TEXT PRIMARY KEY, email TEXT REFERENCES accounts(email) ON DELETE RESTRICT, payment_id INTEGER NOT NULL, FOREIGN KEY (email, payment_id) REFERENCES payments(email, id) 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)'),