WIP: add ability to specify non-dollar currencies #37

Closed
asa wants to merge 6 commits from asa/capsul-flask:currency into main
17 changed files with 130 additions and 101 deletions

View file

@ -34,6 +34,7 @@ Interested in learning more? How about a trip to the the `docs/` folder:
- [With docker-compose](./docs/local-set-up.md#docker_compose) - [With docker-compose](./docs/local-set-up.md#docker_compose)
- [**Configuring `capsul-flask`**](./docs/configuration.md) - [**Configuring `capsul-flask`**](./docs/configuration.md)
- [Example configuration from capsul.org (production)](./docs/configuration.md#example) - [Example configuration from capsul.org (production)](./docs/configuration.md#example)
- [Configuring Currency](./docs/configuration.md#currency)
- [Configuration-type-stuff that lives in the database ](./docs/configuration.md#config_that_lives_in_db) - [Configuration-type-stuff that lives in the database ](./docs/configuration.md#config_that_lives_in_db)
- [Loading variables from files (docker secrets)](./docs/configuration.md#docker_secrets) - [Loading variables from files (docker secrets)](./docs/configuration.md#docker_secrets)
- [**`capsul-flask`'s relationship to its Database Server**](./docs/database.md) - [**`capsul-flask`'s relationship to its Database Server**](./docs/database.md)

View file

@ -74,7 +74,13 @@ app.config.from_mapping(
#STRIPE_WEBHOOK_SECRET=os.environ.get("STRIPE_WEBHOOK_SECRET", default="") #STRIPE_WEBHOOK_SECRET=os.environ.get("STRIPE_WEBHOOK_SECRET", default="")
BTCPAY_PRIVATE_KEY=os.environ.get("BTCPAY_PRIVATE_KEY", default="").replace("\\n", "\n"), BTCPAY_PRIVATE_KEY=os.environ.get("BTCPAY_PRIVATE_KEY", default="").replace("\\n", "\n"),
BTCPAY_URL=os.environ.get("BTCPAY_URL", default="") BTCPAY_URL=os.environ.get("BTCPAY_URL", default=""),
CURRENCY_NAME=os.environ.get("CURRENCY_NAME", default="dollar"), # display name (singular)
CURRENCY_SYMBOL=os.environ.get("CURRENCY_SYMBOL", default="$"), # display prefix symbol
CURRENCY_SWIPE_NAME=os.environ.get("CURRENCY_SWIPE_NAME", default="usd"), # identifier swipe expects in swipe.checkout.session.create
CURRENCY_BTCPAY_NAME=os.environ.get("CURRENCY_BTCPAY_NAME", default="USD"), # identifier btcpay expects in BTCPAY_CLIENT.create_invoice
CURRENCY_SWIPE_CONVERSION_FACTOR=os.environ.get("CURRENCY_SWIPE_CONVERSION_FACTOR", default=100) # swipe wants to work in the smallest unit - pennies, cents, etc
) )
app.config['HUB_URL'] = os.environ.get("HUB_URL", default=app.config['BASE_URL']) app.config['HUB_URL'] = os.environ.get("HUB_URL", default=app.config['BASE_URL'])

View file

@ -121,7 +121,7 @@ def clean_up_unresolved_btcpay_invoices():
if btcpay_invoice['status'] == "complete": if btcpay_invoice['status'] == "complete":
current_app.logger.info( current_app.logger.info(
f"resolving btcpay invoice {invoice_id} " f"resolving btcpay invoice {invoice_id} "
f"({unresolved_invoice['email']}, ${unresolved_invoice['dollars']}) as completed " f"({unresolved_invoice['email']}, {config['CURRENCY_SYMBOL']}{unresolved_invoice['amount']}) as completed "
) )
resolved_invoice_email = get_model().btcpay_invoice_resolved(invoice_id, True) resolved_invoice_email = get_model().btcpay_invoice_resolved(invoice_id, True)
@ -131,13 +131,13 @@ def clean_up_unresolved_btcpay_invoices():
elif days >= 1: elif days >= 1:
current_app.logger.info( current_app.logger.info(
f"resolving btcpay invoice {invoice_id} " f"resolving btcpay invoice {invoice_id} "
f"({unresolved_invoice['email']}, ${unresolved_invoice['dollars']}) as invalidated, " f"({unresolved_invoice['email']}, {config['CURRENCY_SYMBOL']}{unresolved_invoice['amount']}) as invalidated, "
f"btcpay server invoice status: {btcpay_invoice['status']}" f"btcpay server invoice status: {btcpay_invoice['status']}"
) )
get_model().btcpay_invoice_resolved(invoice_id, False) get_model().btcpay_invoice_resolved(invoice_id, False)
get_model().delete_payment_session("btcpay", invoice_id) get_model().delete_payment_session("btcpay", invoice_id)
delete_at_account_balance_dollars = -10 delete_at_account_balance_amount = -10
def get_warning_headline(warning_id, pluralize_capsul): def get_warning_headline(warning_id, pluralize_capsul):
return dict( return dict(
@ -150,23 +150,23 @@ def get_warning_headline(warning_id, pluralize_capsul):
zero_now= ( zero_now= (
f"You have run out of funds! You will no longer be able to create Capsuls.\n\n" 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 " 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" "reaches a -{config['CURRENCY_SYMBOL']}10 balance, at which point they will be deleted.\n\n"
), ),
delete_1w= ( delete_1w= (
"You have run out of funds and have not refilled your account.\n\n" "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"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} " f"However, your account will reach a -{config['CURRENCY_SYMBOL']}10 balance some time next week and your Capsul{pluralize_capsul} "
"will be deleted.\n\n" "will be deleted.\n\n"
), ),
delete_1d= ( delete_1d= (
"You have run out of funds and have not refilled your account.\n\n" "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"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"However, your account will reach a -{config['CURRENCY_SYMBOL']}10 balance by this time tomorrow and "
f"your Capsul{pluralize_capsul} will be deleted.\n\n" f"your Capsul{pluralize_capsul} will be deleted.\n\n"
f"Last chance to deposit funds now and keep your Capsul{pluralize_capsul} running! " f"Last chance to deposit funds now and keep your Capsul{pluralize_capsul} running! "
), ),
delete_now= ( delete_now= (
f"Your account reached a -$10 balance and your Capsul{pluralize_capsul} were deleted." f"Your account reached a -{config['CURRENCY_SYMBOL']}10 balance and your Capsul{pluralize_capsul} were deleted."
) )
)[warning_id] )[warning_id]
@ -205,7 +205,7 @@ def get_warnings_list():
), ),
dict( dict(
id='delete_1w', id='delete_1w',
get_active=lambda balance_1w, balance_1d, balance_now: balance_1w < delete_at_account_balance_dollars, get_active=lambda balance_1w, balance_1d, balance_now: balance_1w < delete_at_account_balance_amount,
get_subject=lambda pluralize_capsul: 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",
get_body=lambda base_url, pluralize_capsul: ( get_body=lambda base_url, pluralize_capsul: (
f"{get_warning_headline('delete_1w', pluralize_capsul)}" f"{get_warning_headline('delete_1w', pluralize_capsul)}"
@ -216,7 +216,7 @@ def get_warnings_list():
), ),
dict( dict(
id='delete_1d', id='delete_1d',
get_active=lambda balance_1w, balance_1d, balance_now: balance_1d < delete_at_account_balance_dollars, get_active=lambda balance_1w, balance_1d, balance_now: balance_1d < delete_at_account_balance_amount,
get_subject=lambda pluralize_capsul: 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",
get_body=lambda base_url, pluralize_capsul: ( get_body=lambda base_url, pluralize_capsul: (
f"{get_warning_headline('delete_1d', pluralize_capsul)}" f"{get_warning_headline('delete_1d', pluralize_capsul)}"
@ -225,7 +225,7 @@ def get_warnings_list():
), ),
dict( dict(
id='delete_now', id='delete_now',
get_active=lambda balance_1w, balance_1d, balance_now: balance_now < delete_at_account_balance_dollars, get_active=lambda balance_1w, balance_1d, balance_now: balance_now < delete_at_account_balance_amount,
get_subject=lambda pluralize_capsul: f"Capsul{pluralize_capsul} Deleted", get_subject=lambda pluralize_capsul: f"Capsul{pluralize_capsul} Deleted",
get_body=lambda base_url, pluralize_capsul: ( get_body=lambda base_url, pluralize_capsul: (
f"{get_warning_headline('delete_now', pluralize_capsul)}" f"{get_warning_headline('delete_now', pluralize_capsul)}"

View file

@ -212,12 +212,12 @@ def create():
# if a user deposits $7.50 and then creates an f1-s vm which costs 7.50 a month, # if a user deposits $7.50 and then creates an f1-s vm which costs 7.50 a month,
# then they have to delete the vm and re-create it, they will not be able to, they will have to pay again. # then they have to delete the vm and re-create it, they will not be able to, they will have to pay again.
# so for UX it makes a lot of sense to give a small margin of 25 cents for usability sake # so for UX it makes a lot of sense to give a small margin of 25 cents for usability sake
if vm_size["dollars_per_month"] <= account_balance_in_one_month+0.25: if vm_size["amount_per_month"] <= account_balance_in_one_month+0.25:
month_funded_vm_sizes[key] = vm_size month_funded_vm_sizes[key] = vm_size
one_month_in_hours = float(730.5) one_month_in_hours = float(730.5)
two_hours_as_fraction_of_month = float(2)/one_month_in_hours two_hours_as_fraction_of_month = float(2)/one_month_in_hours
if float(vm_size["dollars_per_month"])*two_hours_as_fraction_of_month <= account_balance: if float(vm_size["amount_per_month"])*two_hours_as_fraction_of_month <= account_balance:
hour_funded_vm_sizes[key] = vm_size hour_funded_vm_sizes[key] = vm_size
if request.method == "POST": if request.method == "POST":
@ -411,11 +411,11 @@ def account_balance():
vm_months = get_vm_months_float(vm, datetime.utcnow()) vm_months = get_vm_months_float(vm, datetime.utcnow())
vms_billed.append(dict( vms_billed.append(dict(
id=vm["id"], id=vm["id"],
dollars_per_month=vm["dollars_per_month"], amount_per_month=vm["amount_per_month"],
created=vm["created"].strftime("%b %d %Y"), created=vm["created"].strftime("%b %d %Y"),
deleted=vm["deleted"].strftime("%b %d %Y") if vm["deleted"] else "N/A", deleted=vm["deleted"].strftime("%b %d %Y") if vm["deleted"] else "N/A",
months=format(vm_months, '.3f'), months=format(vm_months, '.3f'),
dollars=format(vm_months * float(vm["dollars_per_month"]), '.2f') amount=format(vm_months * float(vm["amount_per_month"]), '.2f')
)) ))
return render_template( return render_template(
@ -427,7 +427,7 @@ def account_balance():
btcpay_is_working="BTCPAY_CLIENT" in current_app.config, btcpay_is_working="BTCPAY_CLIENT" in current_app.config,
payments=list(map( payments=list(map(
lambda x: dict( lambda x: dict(
dollars=x["dollars"], amount=x["amount"],
class_name="invalidated" if x["invalidated"] else "", class_name="invalidated" if x["invalidated"] else "",
created=x["created"].strftime("%b %d %Y") created=x["created"].strftime("%b %d %Y")
), ),

View file

@ -50,7 +50,7 @@ def init_app(app, is_running_server):
hasSchemaVersionTable = False hasSchemaVersionTable = False
actionWasTaken = False actionWasTaken = False
schemaVersion = 0 schemaVersion = 0
desiredSchemaVersion = 23 desiredSchemaVersion = 24
cursor = connection.cursor() cursor = connection.cursor()

View file

@ -106,11 +106,11 @@ class DBModel:
return operatingSystems return operatingSystems
def vm_sizes_dict(self): def vm_sizes_dict(self):
self.cursor.execute("SELECT id, dollars_per_month, vcpus, memory_mb, bandwidth_gb_per_month FROM vm_sizes") self.cursor.execute("SELECT id, amount_per_month, vcpus, memory_mb, bandwidth_gb_per_month FROM vm_sizes")
vmSizes = dict() vmSizes = dict()
for row in self.cursor.fetchall(): for row in self.cursor.fetchall():
vmSizes[row[0]] = dict(dollars_per_month=row[1], vcpus=row[2], memory_mb=row[3], bandwidth_gb_per_month=row[4]) vmSizes[row[0]] = dict(amount_per_month=row[1], vcpus=row[2], memory_mb=row[3], bandwidth_gb_per_month=row[4])
return vmSizes return vmSizes
@ -140,13 +140,13 @@ class DBModel:
def list_vms_for_account(self, email): def list_vms_for_account(self, email):
self.cursor.execute(""" self.cursor.execute("""
SELECT vms.id, vms.public_ipv4, vms.public_ipv6, vms.size, vms.shortterm, vms.os, vms.created, vms.deleted, vm_sizes.dollars_per_month SELECT vms.id, vms.public_ipv4, vms.public_ipv6, vms.size, vms.shortterm, vms.os, vms.created, vms.deleted, vm_sizes.amount_per_month
FROM vms JOIN vm_sizes on vms.size = vm_sizes.id FROM vms JOIN vm_sizes on vms.size = vm_sizes.id
WHERE vms.email = %s""", WHERE vms.email = %s""",
(email, ) (email, )
) )
return list(map( return list(map(
lambda x: dict(id=x[0], ipv4=x[1], ipv6=x[2], size=x[3], shortterm=x[4], os=x[5], created=x[6], deleted=x[7], dollars_per_month=x[8]), lambda x: dict(id=x[0], ipv4=x[1], ipv6=x[2], size=x[3], shortterm=x[4], os=x[5], created=x[6], deleted=x[7], amount_per_month=x[8]),
self.cursor.fetchall() self.cursor.fetchall()
)) ))
@ -188,7 +188,7 @@ class DBModel:
def get_vm_detail(self, email, id): def get_vm_detail(self, email, id):
self.cursor.execute(""" self.cursor.execute("""
SELECT vms.id, vms.public_ipv4, vms.public_ipv6, os_images.description, vms.created, vms.deleted, SELECT vms.id, vms.public_ipv4, vms.public_ipv6, os_images.description, vms.created, vms.deleted,
vm_sizes.id, vms.shortterm, vm_sizes.dollars_per_month, vm_sizes.vcpus, vm_sizes.memory_mb, vm_sizes.bandwidth_gb_per_month vm_sizes.id, vms.shortterm, vm_sizes.amount_per_month, vm_sizes.vcpus, vm_sizes.memory_mb, vm_sizes.bandwidth_gb_per_month
FROM vms FROM vms
JOIN os_images on vms.os = os_images.id JOIN os_images on vms.os = os_images.id
JOIN vm_sizes on vms.size = vm_sizes.id JOIN vm_sizes on vms.size = vm_sizes.id
@ -201,7 +201,7 @@ class DBModel:
vm = dict( vm = dict(
id=row[0], ipv4=row[1], ipv6=row[2], os_description=row[3], created=row[4], deleted=row[5], id=row[0], ipv4=row[1], ipv6=row[2], os_description=row[3], created=row[4], deleted=row[5],
size=row[6], shortterm=row[7], dollars_per_month=row[8], vcpus=row[9], memory_mb=row[10], size=row[6], shortterm=row[7], amount_per_month=row[8], vcpus=row[9], memory_mb=row[10],
bandwidth_gb_per_month=row[11], bandwidth_gb_per_month=row[11],
) )
@ -232,32 +232,32 @@ class DBModel:
def list_payments_for_account(self, email): def list_payments_for_account(self, email):
self.cursor.execute(""" self.cursor.execute("""
SELECT id, dollars, invalidated, created SELECT id, amount, invalidated, created
FROM payments WHERE payments.email = %s""", FROM payments WHERE payments.email = %s""",
(email, ) (email, )
) )
return list(map( return list(map(
lambda x: dict(id=x[0], dollars=x[1], invalidated=x[2], created=x[3]), lambda x: dict(id=x[0], amount=x[1], invalidated=x[2], created=x[3]),
self.cursor.fetchall() self.cursor.fetchall()
)) ))
def create_payment_session(self, payment_type, id, email, dollars): def create_payment_session(self, payment_type, id, email, amount):
self.cursor.execute(""" self.cursor.execute("""
INSERT INTO payment_sessions (id, type, email, dollars) INSERT INTO payment_sessions (id, type, email, amount)
VALUES (%s, %s, %s, %s) VALUES (%s, %s, %s, %s)
""", """,
(id, payment_type, email, dollars) (id, payment_type, email, amount)
) )
self.connection.commit() self.connection.commit()
def list_payment_sessions_for_account(self, email): def list_payment_sessions_for_account(self, email):
self.cursor.execute(""" self.cursor.execute("""
SELECT id, type, dollars, created SELECT id, type, amount, created
FROM payment_sessions WHERE email = %s""", FROM payment_sessions WHERE email = %s""",
(email, ) (email, )
) )
return list(map( return list(map(
lambda x: dict(id=x[0], type=x[1], dollars=x[2], created=x[3]), lambda x: dict(id=x[0], type=x[1], amount=x[2], created=x[3]),
self.cursor.fetchall() self.cursor.fetchall()
)) ))
@ -276,21 +276,21 @@ class DBModel:
return None return None
def consume_payment_session(self, payment_type, id, dollars): def consume_payment_session(self, payment_type, id, amount):
self.cursor.execute("SELECT email, dollars FROM payment_sessions WHERE id = %s AND type = %s", (id, payment_type)) self.cursor.execute("SELECT email, amount FROM payment_sessions WHERE id = %s AND type = %s", (id, payment_type))
row = self.cursor.fetchone() row = self.cursor.fetchone()
if row: if row:
if int(row[1]) != int(dollars): if int(row[1]) != int(amount):
current_app.logger.warning(f""" current_app.logger.warning(f"""
{payment_type} gave us a completed payment session with a different dollar amount than what we had recorded!! {payment_type} gave us a completed payment session with a different {current_app.config['CURRENCY_NAME']} amount than what we had recorded!!
id: {id} id: {id}
account: {row[0]} account: {row[0]}
Recorded amount: {int(row[1])} Recorded amount: {int(row[1])}
{payment_type} sent: {int(dollars)} {payment_type} sent: {int(amount)}
""") """)
# not sure what to do here. For now just log and do nothing. # 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( "DELETE FROM payment_sessions WHERE id = %s AND type = %s", (id, payment_type) )
self.cursor.execute( "INSERT INTO payments (email, dollars) VALUES (%s, %s) RETURNING id", (row[0], row[1]) ) self.cursor.execute( "INSERT INTO payments (email, amount) VALUES (%s, %s) RETURNING id", (row[0], row[1]) )
if payment_type == "btcpay": if payment_type == "btcpay":
payment_id = self.cursor.fetchone()[0] payment_id = self.cursor.fetchone()[0]
@ -326,10 +326,10 @@ class DBModel:
def get_unresolved_btcpay_invoices(self): def get_unresolved_btcpay_invoices(self):
self.cursor.execute(""" self.cursor.execute("""
SELECT unresolved_btcpay_invoices.id, payments.created, payments.dollars, unresolved_btcpay_invoices.email SELECT unresolved_btcpay_invoices.id, payments.created, payments.amount, unresolved_btcpay_invoices.email
FROM unresolved_btcpay_invoices JOIN payments on payment_id = payments.id 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())) return list(map(lambda row: dict(id=row[0], created=row[1], amount=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,))

View file

@ -28,27 +28,27 @@ from capsulflask.shared import my_exec_info_message, get_account_balance, averag
bp = Blueprint("payment", __name__, url_prefix="/payment") bp = Blueprint("payment", __name__, url_prefix="/payment")
def validate_dollars(min: float, max: float): def validate_amount(min: float, max: float):
errors = list() errors = list()
dollars = None amount = None
if "dollars" not in request.form: if "amount" not in request.form:
errors.append("dollars is required") errors.append("amount is required")
else: else:
dollars = None amount = None
try: try:
dollars = float(request.form["dollars"]) amount = float(request.form["amount"])
except: except:
errors.append("dollars must be a number") errors.append("amount must be a number")
#current_app.logger.info(f"{str(dollars)} {str(min)} {str(dollars < min)}") #current_app.logger.info(f"{str(amount)} {str(min)} {str(amount < min)}")
if dollars is not None and dollars < min: if amount is not None and amount < min:
errors.append(f"dollars must be {format(min, '.2f')} or more") errors.append(f"amount must be {format(min, '.2f')} or more")
elif dollars is not None and dollars >= max: elif amount is not None and amount >= max:
errors.append(f"dollars must be less than {format(max, '.2f')}") errors.append(f"amount must be less than {format(max, '.2f')}")
current_app.logger.info(f"{len(errors)} {errors}") current_app.logger.info(f"{len(errors)} {errors}")
return (dollars, errors) return (amount, errors)
@bp.route("/btcpay", methods=("GET", "POST")) @bp.route("/btcpay", methods=("GET", "POST"))
@account_required @account_required
@ -64,14 +64,14 @@ def btcpay_payment():
return redirect(url_for("console.account_balance")) return redirect(url_for("console.account_balance"))
if request.method == "POST": if request.method == "POST":
dollars, errors = validate_dollars(0.01, 1000.0) amount, errors = validate_amount(0.01, 1000.0)
#current_app.logger.info(f"{len(errors)} {errors}") #current_app.logger.info(f"{len(errors)} {errors}")
if len(errors) == 0: if len(errors) == 0:
try: try:
invoice = current_app.config['BTCPAY_CLIENT'].create_invoice(dict( invoice = current_app.config['BTCPAY_CLIENT'].create_invoice(dict(
price=float(dollars), price=float(amount),
currency="USD", currency=current_app.config['CURRENCY_BTCPAY_NAME'],
itemDesc="Capsul Cloud Compute", itemDesc="Capsul Cloud Compute",
transactionSpeed="high", transactionSpeed="high",
redirectURL=f"{current_app.config['BASE_URL']}/console/account-balance", redirectURL=f"{current_app.config['BASE_URL']}/console/account-balance",
@ -86,9 +86,9 @@ def btcpay_payment():
# print(invoice) # print(invoice)
current_app.logger.info(f"created btcpay invoice_id={invoice['id']} ( {session['account']}, ${dollars} )") current_app.logger.info(f"created btcpay invoice_id={invoice['id']} ( {session['account']}, {current_app.config['CURRENCY_SYMBOL']}{amount} )")
get_model().create_payment_session("btcpay", invoice["id"], session["account"], dollars) get_model().create_payment_session("btcpay", invoice["id"], session["account"], amount)
return redirect(invoice["url"]) return redirect(invoice["url"])
@ -115,18 +115,18 @@ def poll_btcpay_session(invoice_id):
return [503, "error was thrown when contacting btcpay server"] return [503, "error was thrown when contacting btcpay server"]
if invoice['currency'] != "USD": if invoice['currency'] != current_app.config['CURRENCY_BTCPAY_NAME']:
return [400, "invalid currency"] return [400, "invalid currency"]
dollars = invoice['price'] amount = invoice['price']
current_app.logger.info(f"poll_btcpay_session invoice_id={invoice_id}, status={invoice['status']} dollars={dollars}") current_app.logger.info(f"poll_btcpay_session invoice_id={invoice_id}, status={invoice['status']} amount={amount}")
if invoice['status'] == "paid" or invoice['status'] == "confirmed" or invoice['status'] == "complete": 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, amount)
if success_account: if success_account:
current_app.logger.info(f"{success_account} paid ${dollars} successfully (btcpay_invoice_id={invoice_id})") current_app.logger.info(f"{success_account} paid {current_app.config['CURRENCY_SYMBOL']}{amount} successfully (btcpay_invoice_id={invoice_id})")
if invoice['status'] == "complete": if invoice['status'] == "complete":
resolved_invoice_email = get_model().btcpay_invoice_resolved(invoice_id, True) resolved_invoice_email = get_model().btcpay_invoice_resolved(invoice_id, True)
@ -188,11 +188,11 @@ def stripe_payment():
errors = list() errors = list()
if request.method == "POST": if request.method == "POST":
dollars, errors = validate_dollars(0.5, 1000000) amount, errors = validate_amount(0.5, 1000000)
if len(errors) == 0: if len(errors) == 0:
current_app.logger.info(f"creating stripe checkout session for {session['account']}, ${dollars}") current_app.logger.info(f"creating stripe checkout session for {session['account']}, {current_app.config['CURRENCY_SYMBOL']}{amount}")
checkout_session = stripe.checkout.Session.create( checkout_session = stripe.checkout.Session.create(
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}",
@ -204,16 +204,16 @@ def stripe_payment():
"name": "Capsul Cloud Compute", "name": "Capsul Cloud Compute",
"images": [current_app.config['BASE_URL']+"/static/capsul-product-image.png"], "images": [current_app.config['BASE_URL']+"/static/capsul-product-image.png"],
"quantity": 1, "quantity": 1,
"currency": "usd", "currency": current_app.config['CURRENCY_SWIPE_NAME'],
"amount": int(dollars*100) "amount": int(amount*100)
} }
] ]
) )
stripe_checkout_session_id = checkout_session['id'] stripe_checkout_session_id = checkout_session['id']
current_app.logger.info(f"stripe_checkout_session_id={stripe_checkout_session_id} ( {session['account']}, ${dollars} )") current_app.logger.info(f"stripe_checkout_session_id={stripe_checkout_session_id} ( {session['account']}, {current_app.config['CURRENCY_SYMBOL']}{amount} )")
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"], amount)
# We can't do this because stripe requires a bunch of server-authenticated data to be sent in the hash # 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 # of the URL. I briefly looked into reverse-engineering their proprietary javascript in order to try to figure out
@ -280,17 +280,17 @@ def validate_stripe_checkout_session(stripe_checkout_session_id):
if checkout_session and 'id' in checkout_session and checkout_session['id'] == stripe_checkout_session_id: if checkout_session and 'id' in checkout_session and checkout_session['id'] == stripe_checkout_session_id:
cents = checkout_session['amount_total'] cents = checkout_session['amount_total']
dollars = decimal.Decimal(cents)/100 amount = decimal.Decimal(cents)/100
#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_email = get_model().consume_payment_session("stripe", stripe_checkout_session_id, dollars) success_email = get_model().consume_payment_session("stripe", stripe_checkout_session_id, amount)
if success_email is not None: if success_email is not None:
check_if_shortterm_flag_can_be_unset(success_email) check_if_shortterm_flag_can_be_unset(success_email)
if success_email: if success_email:
return dict(email=success_email, dollars=dollars) return dict(email=success_email, amount=amount)
return None return None
@ -304,7 +304,7 @@ def success():
for _ in range(0, 5): for _ in range(0, 5):
paid = validate_stripe_checkout_session(stripe_checkout_session_id) paid = validate_stripe_checkout_session(stripe_checkout_session_id)
if paid: if paid:
current_app.logger.info(f"{paid['email']} paid ${paid['dollars']} successfully (stripe_checkout_session_id={stripe_checkout_session_id})") current_app.logger.info(f"{paid['email']} paid {current_app.config['CURRENCY_SYMBOL']}{paid['amount']} successfully (stripe_checkout_session_id={stripe_checkout_session_id})")
return redirect(url_for("console.account_balance")) return redirect(url_for("console.account_balance"))
else: else:
@ -325,14 +325,14 @@ def success():
# secret=current_app.config['STRIPE_WEBHOOK_SECRET'] # secret=current_app.config['STRIPE_WEBHOOK_SECRET']
# ) # )
# if event['type'] == 'checkout.session.completed': # if event['type'] == 'checkout.session.completed':
# dollars = event['data']['object']['amount_total'] # amount = event['data']['object']['amount_total']
# stripe_checkout_session_id = event['data']['object']['id'] # stripe_checkout_session_id = event['data']['object']['id']
# #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_account = get_model().consume_payment_session("stripe", stripe_checkout_session_id, amount)
# if success_account: # if success_account:
# print(f"{success_account} paid ${dollars} successfully (stripe_checkout_session_id={stripe_checkout_session_id})") # print(f"{success_account} paid {current_app.config['CURRENCY_SYMBOL']}{amount} successfully (stripe_checkout_session_id={stripe_checkout_session_id})")
# return jsonify({'status': 'success'}) # return jsonify({'status': 'success'})
# except ValueError as e: # except ValueError as e:

View file

@ -0,0 +1,6 @@
ALTER TABLE vm_sizes RENAME amount_per_month TO dollars_per_month;
ALTER TABLE payments RENAME amount TO dollars;
ALTER TABLE payment_sessions RENAME amount TO dollars;
UPDATE schemaversion SET version = 23;

View file

@ -0,0 +1,6 @@
ALTER TABLE vm_sizes RENAME dollars_per_month TO amount_per_month;
ALTER TABLE payments RENAME dollars TO amount;
ALTER TABLE payment_sessions RENAME dollars TO amount;
UPDATE schemaversion SET version = 24;

View file

@ -62,14 +62,14 @@ def authorized_as_hub(headers):
def get_account_balance(vms, payments, as_of): def get_account_balance(vms, payments, as_of):
vm_cost_dollars = 0.0 vm_cost_amount = 0.0
for vm in vms: for vm in vms:
vm_months = get_vm_months_float(vm, as_of) vm_months = get_vm_months_float(vm, as_of)
vm_cost_dollars += vm_months * float(vm["dollars_per_month"]) vm_cost_amount += vm_months * float(vm["amount_per_month"])
payment_dollars_total = float( sum(map(lambda x: 0 if x["invalidated"] else x["dollars"], payments)) ) payment_amount_total = float( sum(map(lambda x: 0 if x["invalidated"] else x["amount"], payments)) )
return payment_dollars_total - vm_cost_dollars return payment_amount_total - vm_cost_amount
average_number_of_days_in_a_month = 30.44 average_number_of_days_in_a_month = 30.44

View file

@ -4,7 +4,7 @@
{% block content %} {% block content %}
<div class="row third-margin"> <div class="row third-margin">
<h1>Account Balance: ${{ account_balance }}</h1> <h1>Account Balance: {{config["CURRENCY_SYMBOL"]}}{{ account_balance }}</h1>
</div> </div>
<div class="half-margin"> <div class="half-margin">
@ -30,7 +30,7 @@
<tbody> <tbody>
{% for payment in payments %} {% for payment in payments %}
<tr> <tr>
<td class="{{ payment['class_name'] }}">${{ payment["dollars"] }}</td> <td class="{{ payment['class_name'] }}">{{config["CURRENCY_SYMBOL"]}}{{ payment["amount"] }}</td>
<td class="{{ payment['class_name'] }}">{{payment["created"]}}</td> <td class="{{ payment['class_name'] }}">{{payment["created"]}}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -81,9 +81,9 @@
<th>id</th> <th>id</th>
<th>created</th> <th>created</th>
<th>deleted</th> <th>deleted</th>
<th>$/month</th> <th>{{config["CURRENCY_SYMBOL"]}}/month</th>
<th>months</th> <th>months</th>
<th>$ billed</th> <th>{{config["CURRENCY_SYMBOL"]}} billed</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -92,9 +92,9 @@
<td>{{ vm["id"] }}</td> <td>{{ vm["id"] }}</td>
<td>{{ vm["created"] }}</td> <td>{{ vm["created"] }}</td>
<td>{{ vm["deleted"] }}</td> <td>{{ vm["deleted"] }}</td>
<td>${{ vm["dollars_per_month"] }}</td> <td>{{config["CURRENCY_SYMBOL"]}}{{ vm["amount_per_month"] }}</td>
<td>{{ vm["months"] }}</td> <td>{{ vm["months"] }}</td>
<td>${{ vm["dollars"] }}</td> <td>{{config["CURRENCY_SYMBOL"]}}{{ vm["amount"] }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -13,10 +13,10 @@
<form method="POST" action="/payment/btcpay"> <form method="POST" action="/payment/btcpay">
<div class="row"> <div class="row">
<span> <span>
<label for="btcpay-input-price">$</label> <label for="btcpay-input-price">{{config['CURRENCY_SYMBOL']}}</label>
<input <input
id="btcpay-input-price" id="btcpay-input-price"
name="dollars" name="amount"
type="text" type="text"
/> />
</span> </span>

View file

@ -76,8 +76,8 @@
{% endif %} {% endif %}
</div> </div>
<div class="row justify-start"> <div class="row justify-start">
<label class="align" for="dollars_per_month">Monthly Cost</label> <label class="align" for="amount_per_month">Monthly Cost</label>
<span id="dollars_per_month">${{ vm['dollars_per_month'] }}</span> <span id="amount_per_month">{{config['CURRENCY_SYMBOL']}}{{ vm['amount_per_month'] }}</span>
</div> </div>
<div class="row justify-start"> <div class="row justify-start">
<label class="align" for="ipv4">IPv4 Address</label> <label class="align" for="ipv4">IPv4 Address</label>

View file

@ -13,19 +13,19 @@
============ ============
type monthly* cpus mem ssd net* type monthly* cpus mem ssd net*
----- ------- ---- --- --- --- ----- ------- ---- --- --- ---
f1-xs $5.00 1 512M 25G .5TB f1-xs {{config['CURRENCY_SYMBOL']}}5.00 1 512M 25G .5TB
f1-s $7.50 1 1024M 25G 1TB f1-s {{config['CURRENCY_SYMBOL']}}7.50 1 1024M 25G 1TB
f1-m $12.50 1 2048M 25G 2TB f1-m {{config['CURRENCY_SYMBOL']}}12.50 1 2048M 25G 2TB
f1-l $20.00 2 3072M 25G 3TB f1-l {{config['CURRENCY_SYMBOL']}}20.00 2 3072M 25G 3TB
f1-x $27.50 3 4096M 25G 4TB f1-x {{config['CURRENCY_SYMBOL']}}27.50 3 4096M 25G 4TB
f1-xx $50.00 4 8192M 25G 5TB f1-xx {{config['CURRENCY_SYMBOL']}}50.00 4 8192M 25G 5TB
* all VMs come standard with one public IPv4 address * all VMs come standard with one public IPv4 address
* vms are billed for a minimum of 1 hour upon creation</pre> * vms are billed for a minimum of 1 hour upon creation</pre>
<pre> <pre>
Your <a href="/console/account-balance">current account balance</a>: ${{ account_balance }} {% if account_balance != account_balance_in_one_month %} Your <a href="/console/account-balance">current account balance</a>: {{config['CURRENCY_SYMBOL']}}{{ account_balance }} {% if account_balance != account_balance_in_one_month %}
Projected account balance in one month: ${{ account_balance_in_one_month }}{% endif %} Projected account balance in one month: {{config['CURRENCY_SYMBOL']}}{{ account_balance_in_one_month }}{% endif %}
</pre> </pre>
{% if cant_afford %} {% if cant_afford %}
<p>Please <a href="/console/account-balance">fund your account</a> in order to create Capsuls</p> <p>Please <a href="/console/account-balance">fund your account</a> in order to create Capsuls</p>

View file

@ -22,7 +22,7 @@
{% for vm_size_key, vm_size in vm_sizes.items() %} {% for vm_size_key, vm_size in vm_sizes.items() %}
<tr> <tr>
<td>{{ vm_size_key }}</td> <td>{{ vm_size_key }}</td>
<td>${{ vm_size['dollars_per_month'] }}</td> <td>{{config['CURRENCY_SYMBOL']}}{{ vm_size['amount_per_month'] }}</td>
<td>{{ vm_size['vcpus'] }}</td> <td>{{ vm_size['vcpus'] }}</td>
<td>{{ vm_size['memory_mb'] }}</td> <td>{{ vm_size['memory_mb'] }}</td>
<td>25G</td> <td>25G</td>

View file

@ -26,8 +26,8 @@
<div class="row half-margin"> <div class="row half-margin">
<form method="post"> <form method="post">
<div class="row justify-start"> <div class="row justify-start">
<label for="dollars">$</label> <label for="amount">{{config['CURRENCY_SYMBOL']}}</label>
<input type="number" id="dollars" name="dollars"></input> <input type="number" id="amount" name="amount"></input>
</div> </div>
<div class="row justify-end"> <div class="row justify-end">
<input type="submit" value="Pay With Stripe"> <input type="submit" value="Pay With Stripe">

View file

@ -63,6 +63,16 @@ BTCPAY_URL="https://beeteeceepae2.cyberia.club"
BTCPAY_PRIVATE_KEY='-----BEGIN EC PRIVATE KEY-----\n<redacted>\n-----END EC PRIVATE KEY-----' BTCPAY_PRIVATE_KEY='-----BEGIN EC PRIVATE KEY-----\n<redacted>\n-----END EC PRIVATE KEY-----'
``` ```
## <a name="currency"></a>Configuring Currency
By default, capsul operates in USD. You can change that with the currency configuration settings:
```
CURRENCY_NAME=dollar <the name of the currency, singular>
CURRENCY_SYMBOL=$ <the symbol to refer to the currency>
CURRENCY_STRIPE_NAME=usd <a string stripe uses to identify what currency a payment is being requested in. £ is gbp>
CURRENCY_BTCPAY_NAME=USD <likewise for btcpay>
CURRENCY_STRIPE_CONVERSION_FACTOR=100 <stripe wants to work in the smallest unit - cents, pennies etc>
```
**Don't change the currency once capsul is up and running!** Amounts of money are stored in the database as plain numbers and won't be converted, so if someone has $10 and you move to using gbp they'll now have £10 in their account.
## <a name="config_that_lives_in_db"></a>Configuration-type-stuff that lives in the database ## <a name="config_that_lives_in_db"></a>Configuration-type-stuff that lives in the database
- `hosts` table: - `hosts` table: