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:
parent
67120e9461
commit
e9dcf80f6c
1
Pipfile
1
Pipfile
@ -28,7 +28,6 @@ stripe = "*"
|
|||||||
matplotlib = "*"
|
matplotlib = "*"
|
||||||
requests = "*"
|
requests = "*"
|
||||||
python-dotenv = "*"
|
python-dotenv = "*"
|
||||||
bitpay = "*"
|
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
|
|
||||||
|
16
Pipfile.lock
generated
16
Pipfile.lock
generated
@ -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",
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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())
|
||||||
|
|
||||||
|
0
capsulflask/btcpay/__init__.py
Normal file
0
capsulflask/btcpay/__init__.py
Normal file
121
capsulflask/btcpay/client.py
Normal file
121
capsulflask/btcpay/client.py
Normal 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)
|
3
capsulflask/btcpay/exceptions.py
Normal file
3
capsulflask/btcpay/exceptions.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
class BtcPayArgumentError(Exception): pass
|
||||||
|
class BtcPayBtcPayError(Exception): pass
|
||||||
|
class BtcPayConnectionError(Exception): pass
|
68
capsulflask/btcpay/key_utils.py
Normal file
68
capsulflask/btcpay/key_utils.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
for account in accounts:
|
print("cron_task: starting ensure_vms_and_db_are_synced")
|
||||||
vms = get_model().list_vms_for_account(account['email'])
|
ensure_vms_and_db_are_synced()
|
||||||
payments = get_model().list_payments_for_account(account['email'])
|
print("cron_task: finished ensure_vms_and_db_are_synced")
|
||||||
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 = [
|
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(
|
dict(
|
||||||
id='zero_1w',
|
id='zero_1w',
|
||||||
active=balance_1w < 0,
|
get_active=lambda balance_1w, balance_1d, balance_now: balance_1w < 0,
|
||||||
subject="Capsul One Week Payment Reminder",
|
get_subject=lambda _: "Capsul One Week Payment Reminder",
|
||||||
body=("According to our calculations, your Capsul account will run out of funds in one week.\n\n"
|
get_body=lambda base_url, pluralize_capsul: (
|
||||||
f"Log in now to re-fill your account! {current_app.config['BASE_URL']}/console/account-balance\n\n"
|
f"{get_warning_headline('zero_1w', pluralize_capsul)}"
|
||||||
"If you believe you have recieved this message in error, please let us know: support@cyberia.club")
|
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(
|
dict(
|
||||||
id='zero_1d',
|
id='zero_1d',
|
||||||
active=balance_1d < 0,
|
get_active=lambda balance_1w, balance_1d, balance_now: balance_1d < 0,
|
||||||
subject="Capsul One Day Payment Reminder",
|
get_subject=lambda _: "Capsul One Day Payment Reminder",
|
||||||
body=("According to our calculations, your Capsul account will run out of funds tomorrow.\n\n"
|
get_body=lambda base_url, pluralize_capsul: (
|
||||||
f"Log in now to re-fill your account! {current_app.config['BASE_URL']}/console/account-balance\n\n"
|
f"{get_warning_headline('zero_1d', pluralize_capsul)}"
|
||||||
"If you believe you have recieved this message in error, please let us know: support@cyberia.club")
|
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(
|
dict(
|
||||||
id='zero_now',
|
id='zero_now',
|
||||||
active=balance_now < 0,
|
get_active=lambda balance_1w, balance_1d, balance_now: balance_now < 0,
|
||||||
subject="Your Capsul Account is No Longer Funded",
|
get_subject=lambda _: "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"
|
get_body=lambda base_url, pluralize_capsul: (
|
||||||
f"As a courtesy, we'll let your existing Capsul{pluralize_capsul} keep running until your account "
|
f"{get_warning_headline('zero_now', pluralize_capsul)}"
|
||||||
"reaches a -$10 balance, at which point they will be deleted.\n\n"
|
f"Log in now to re-fill your account! {base_url}/console/account-balance\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}, "
|
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")
|
"would like to request backups, or de-activate your account, please contact: support@cyberia.club"
|
||||||
|
)
|
||||||
),
|
),
|
||||||
dict(
|
dict(
|
||||||
id='delete_1w',
|
id='delete_1w',
|
||||||
active=balance_1w < delete_at_account_balance,
|
get_active=lambda balance_1w, balance_1d, balance_now: balance_1w < delete_at_account_balance_dollars,
|
||||||
subject=f"Your Capsul{pluralize_capsul} Will be Deleted In Less Than a Week",
|
get_subject=lambda pluralize_capsul: 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"
|
get_body=lambda base_url, pluralize_capsul: (
|
||||||
f"As a courtesy, we have let your existing Capsul{pluralize_capsul} keep running. "
|
f"{get_warning_headline('delete_1w', pluralize_capsul)}"
|
||||||
f"However, your account will reach a -$10 balance some time next week and your Capsul{pluralize_capsul}"
|
f"Log in now to re-fill your account! {base_url}/console/account-balance\n\n"
|
||||||
"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}, "
|
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")
|
"would like to request backups, or de-activate your account, please contact: support@cyberia.club"
|
||||||
|
)
|
||||||
),
|
),
|
||||||
dict(
|
dict(
|
||||||
id='delete_1d',
|
id='delete_1d',
|
||||||
active=balance_1d < delete_at_account_balance,
|
get_active=lambda balance_1w, balance_1d, balance_now: balance_1d < delete_at_account_balance_dollars,
|
||||||
subject=f"Last Chance to Save your Capsul{pluralize_capsul}: Gone Tomorrow",
|
get_subject=lambda pluralize_capsul: 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"
|
get_body=lambda base_url, pluralize_capsul: (
|
||||||
f"As a courtesy, we have let your existing Capsul{pluralize_capsul} keep running. "
|
f"{get_warning_headline('delete_1d', pluralize_capsul)}"
|
||||||
f"However, your account will reach a -$10 balance tomorrow and your Capsul{pluralize_capsul} will be deleted.\n\n"
|
f"{base_url}/console/account-balance"
|
||||||
f"Last chance to deposit funds now and keep your Capsul{pluralize_capsul} running! {current_app.config['BASE_URL']}/console/account-balance")
|
)
|
||||||
),
|
),
|
||||||
dict(
|
dict(
|
||||||
id='delete_now',
|
id='delete_now',
|
||||||
active=balance_now < delete_at_account_balance,
|
get_active=lambda balance_1w, balance_1d, balance_now: balance_now < delete_at_account_balance_dollars,
|
||||||
subject=f"Capsul{pluralize_capsul} Deleted",
|
get_subject=lambda pluralize_capsul: f"Capsul{pluralize_capsul} Deleted",
|
||||||
body=(f"Your account reached a -$10 balance and your Capsul{pluralize_capsul} were 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:
|
||||||
|
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() + timedelta(days=7))
|
||||||
|
balance_1d = get_account_balance(vms, payments, datetime.utcnow() + timedelta(days=1))
|
||||||
|
balance_now = get_account_balance(vms, payments, datetime.utcnow())
|
||||||
|
current_warning = account['account_balance_warning']
|
||||||
|
|
||||||
|
pluralize_capsul = "s" if len(vms) > 1 else ""
|
||||||
|
|
||||||
|
warnings = get_warnings_list()
|
||||||
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
|
|
@ -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')
|
||||||
)
|
)
|
@ -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()))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -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,17 +90,19 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/stripe", methods=("GET", "POST"))
|
@bp.route("/stripe", methods=("GET", "POST"))
|
||||||
@ -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,13 +162,7 @@ def stripe_payment():
|
|||||||
stripe_public_key=current_app.config["STRIPE_PUBLISHABLE_KEY"]
|
stripe_public_key=current_app.config["STRIPE_PUBLISHABLE_KEY"]
|
||||||
)
|
)
|
||||||
|
|
||||||
@bp.route("/stripe/success", methods=("GET",))
|
def validate_stripe_checkout_session(stripe_checkout_session_id):
|
||||||
def success():
|
|
||||||
stripe_checkout_session_id = request.args.get('session_id')
|
|
||||||
if not stripe_checkout_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(
|
checkout_session_completed_events = stripe.Event.list(
|
||||||
type='checkout.session.completed',
|
type='checkout.session.completed',
|
||||||
created={
|
created={
|
||||||
@ -172,12 +180,28 @@ def success():
|
|||||||
|
|
||||||
#consume_payment_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_payment_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_payment_session("stripe", stripe_checkout_session_id, dollars)
|
success_email = get_model().consume_payment_session("stripe", stripe_checkout_session_id, dollars)
|
||||||
|
|
||||||
if success_account:
|
if success_email:
|
||||||
print(f"{success_account} paid ${dollars} successfully (stripe_checkout_session_id={stripe_checkout_session_id})")
|
return dict(email=success_email, dollars=dollars)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@bp.route("/stripe/success", methods=("GET",))
|
||||||
|
def success():
|
||||||
|
stripe_checkout_session_id = request.args.get('session_id')
|
||||||
|
if not stripe_checkout_session_id:
|
||||||
|
print("/payment/stripe/success returned 400: missing required URL parameter session_id")
|
||||||
|
abort(400, "missing required URL parameter session_id")
|
||||||
|
else:
|
||||||
|
for _ in range(0, 5):
|
||||||
|
paid = validate_stripe_checkout_session(stripe_checkout_session_id)
|
||||||
|
if paid:
|
||||||
|
print(f"{paid['email']} paid ${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")
|
||||||
|
|
||||||
|
@ -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;
|
@ -116,6 +116,10 @@ form {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pre.wrap {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
label.align {
|
label.align {
|
||||||
min-width: 10em;
|
min-width: 10em;
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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">
|
||||||
|
Loading…
Reference in New Issue
Block a user