btcpay generating invoices and payments can be invalidated
This commit is contained in:
		@ -50,7 +50,8 @@ Run the app in gunicorn
 | 
			
		||||
.venv/bin/gunicorn --bind 127.0.0.1:5000 capsulflask:app
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## postgres database schema management
 | 
			
		||||
 | 
			
		||||
# postgres database schema management
 | 
			
		||||
 | 
			
		||||
capsulflask has a concept of a schema version. When the application starts, it will query the database for a table named
 | 
			
		||||
`schemaversion` that has one row and one column (`version`). If the `version` it finds is not equal to the `desiredSchemaVersion` variable set in `db.py`, it will run  migration scripts from the `schema_migrations` folder one by one until the `schemaversion` table shows the correct version.
 | 
			
		||||
@ -62,7 +63,8 @@ For example, the script named `02_up_xyz.sql` should contain code that migrates
 | 
			
		||||
In general, for safety, schema version upgrades should not delete data. Schema version downgrades will simply throw an error and exit for now. 
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## how to setup btcpay server
 | 
			
		||||
 | 
			
		||||
# how to setup btcpay server
 | 
			
		||||
 | 
			
		||||
Generate a private key and the accompanying bitpay SIN for the bitpay API client. 
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,8 @@
 | 
			
		||||
import stripe
 | 
			
		||||
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
import stripe
 | 
			
		||||
from bitpay import client as btcpay
 | 
			
		||||
from dotenv import load_dotenv, find_dotenv
 | 
			
		||||
from flask import Flask
 | 
			
		||||
from flask_mail import Mail
 | 
			
		||||
@ -31,7 +33,8 @@ app.config.from_mapping(
 | 
			
		||||
  STRIPE_PUBLISHABLE_KEY=os.environ.get("STRIPE_PUBLISHABLE_KEY", default=""),
 | 
			
		||||
  #STRIPE_WEBHOOK_SECRET=os.environ.get("STRIPE_WEBHOOK_SECRET", default="")
 | 
			
		||||
 | 
			
		||||
  BTCPAY_PRIVATE_KEY=os.environ.get("BTCPAY_PRIVATE_KEY", default="")
 | 
			
		||||
  BTCPAY_PRIVATE_KEY=os.environ.get("BTCPAY_PRIVATE_KEY", default=""),
 | 
			
		||||
  BTCPAY_URL=os.environ.get("BTCPAY_URL", default="https://btcpay.cyberia.club")
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
stripe.api_key = app.config['STRIPE_SECRET_KEY']
 | 
			
		||||
@ -39,18 +42,18 @@ stripe.api_version = app.config['STRIPE_API_VERSION']
 | 
			
		||||
 | 
			
		||||
app.config['FLASK_MAIL_INSTANCE'] = Mail(app)
 | 
			
		||||
app.config['VIRTUALIZATION_MODEL'] = virt_model.MockVirtualization()
 | 
			
		||||
app.config['BTCPAY_CLIENT'] = btcpay.Client(api_uri=app.config['BTCPAY_URL'], pem=app.config['BTCPAY_PRIVATE_KEY'])
 | 
			
		||||
 | 
			
		||||
from capsulflask import db
 | 
			
		||||
 | 
			
		||||
db.init_app(app)
 | 
			
		||||
 | 
			
		||||
from capsulflask import auth, landing, console, payment_stripe, payment_btcpay, metrics
 | 
			
		||||
from capsulflask import auth, landing, console, payment, metrics
 | 
			
		||||
 | 
			
		||||
app.register_blueprint(landing.bp)
 | 
			
		||||
app.register_blueprint(auth.bp)
 | 
			
		||||
app.register_blueprint(console.bp)
 | 
			
		||||
app.register_blueprint(payment_stripe.bp)
 | 
			
		||||
app.register_blueprint(payment_btcpay.bp)
 | 
			
		||||
app.register_blueprint(payment.bp)
 | 
			
		||||
app.register_blueprint(metrics.bp)
 | 
			
		||||
 | 
			
		||||
app.add_url_rule("/", endpoint="index")
 | 
			
		||||
 | 
			
		||||
@ -254,7 +254,7 @@ def get_account_balance():
 | 
			
		||||
    vm_months = ( end_datetime - vm["created"] ).days / average_number_of_days_in_a_month
 | 
			
		||||
    vm_cost_dollars += vm_months * float(vm["dollars_per_month"])
 | 
			
		||||
 | 
			
		||||
  payment_dollars_total = float( sum(map(lambda x: x["dollars"], get_payments())) )
 | 
			
		||||
  payment_dollars_total = float( sum(map(lambda x: 0 if x["invalidated"] else x["dollars"], get_payments())) )
 | 
			
		||||
 | 
			
		||||
  return payment_dollars_total - vm_cost_dollars
 | 
			
		||||
 | 
			
		||||
@ -268,8 +268,8 @@ def account_balance():
 | 
			
		||||
 | 
			
		||||
  for vm in get_vms():
 | 
			
		||||
    end_datetime = vm["deleted"] if vm["deleted"] else datetime.utcnow()
 | 
			
		||||
    print(end_datetime)
 | 
			
		||||
    print(vm["created"])
 | 
			
		||||
    # print(end_datetime)
 | 
			
		||||
    # print(vm["created"])
 | 
			
		||||
    vm_months = (end_datetime - vm["created"]).days / average_number_of_days_in_a_month
 | 
			
		||||
    vms_billed.append(dict(
 | 
			
		||||
      id=vm["id"], 
 | 
			
		||||
@ -284,7 +284,14 @@ def account_balance():
 | 
			
		||||
    "account-balance.html", 
 | 
			
		||||
    has_vms=len(vms_billed)>0, 
 | 
			
		||||
    vms_billed=vms_billed,
 | 
			
		||||
    payments=list(map(lambda x: dict(dollars=x["dollars"], created=x["created"].strftime("%b %d %Y")), payments)), 
 | 
			
		||||
    payments=list(map(
 | 
			
		||||
      lambda x: dict(
 | 
			
		||||
        dollars=x["dollars"], 
 | 
			
		||||
        class_name="invalidated" if x["invalidated"] else "", 
 | 
			
		||||
        created=x["created"].strftime("%b %d %Y")
 | 
			
		||||
      ), 
 | 
			
		||||
      payments
 | 
			
		||||
    )), 
 | 
			
		||||
    has_payments=len(payments)>0, 
 | 
			
		||||
    account_balance=format(account_balance, '.2f')
 | 
			
		||||
  )
 | 
			
		||||
@ -146,38 +146,38 @@ class DBModel:
 | 
			
		||||
 | 
			
		||||
  def list_payments_for_account(self, email):
 | 
			
		||||
    self.cursor.execute(""" 
 | 
			
		||||
      SELECT  payments.dollars, payments.created
 | 
			
		||||
      SELECT  dollars, invalidated, created
 | 
			
		||||
      FROM payments WHERE payments.email = %s""", 
 | 
			
		||||
      (email, )
 | 
			
		||||
    )
 | 
			
		||||
    return list(map(
 | 
			
		||||
      lambda x: dict(dollars=x[0], created=x[1]), 
 | 
			
		||||
      lambda x: dict(dollars=x[0], invalidated=x[1], created=x[2]), 
 | 
			
		||||
      self.cursor.fetchall()
 | 
			
		||||
    ))
 | 
			
		||||
  
 | 
			
		||||
  def create_stripe_checkout_session(self, id, email, dollars):
 | 
			
		||||
  def create_payment_session(self, payment_type, id, email, dollars):
 | 
			
		||||
    self.cursor.execute(""" 
 | 
			
		||||
      INSERT INTO stripe_checkout_sessions (id, email, dollars)
 | 
			
		||||
      VALUES  (%s, %s, %s)
 | 
			
		||||
      INSERT INTO payment_sessions (id, type, email, dollars)
 | 
			
		||||
      VALUES  (%s, %s, %s, %s)
 | 
			
		||||
      """, 
 | 
			
		||||
      (id, email, dollars)
 | 
			
		||||
      (id, payment_type, email, dollars)
 | 
			
		||||
    )
 | 
			
		||||
    self.connection.commit()
 | 
			
		||||
 | 
			
		||||
  def consume_stripe_checkout_session(self, id, dollars):
 | 
			
		||||
    self.cursor.execute("SELECT email, dollars FROM stripe_checkout_sessions WHERE id = %s", (id,))
 | 
			
		||||
  def consume_payment_session(self, payment_type, id, dollars):
 | 
			
		||||
    self.cursor.execute("SELECT email, dollars FROM payment_sessions WHERE id = %s AND type = %s", (id, payment_type))
 | 
			
		||||
    rows = self.cursor.fetchall()
 | 
			
		||||
    if len(rows) > 0:
 | 
			
		||||
      if int(rows[0][1]) != int(dollars):
 | 
			
		||||
        print(f"""
 | 
			
		||||
          Stripe sent us a completed checkout session with a different dollar amount than what we had recorded!!
 | 
			
		||||
          stripe_checkout_session_id: {id}
 | 
			
		||||
          {payment_type} gave us a completed payment session with a different dollar amount than what we had recorded!!
 | 
			
		||||
          id: {id}
 | 
			
		||||
          account: {rows[0][0]}
 | 
			
		||||
          Recorded amount: {int(rows[0][1])}
 | 
			
		||||
          Stripe sent: {int(dollars)}
 | 
			
		||||
          {payment_type} sent: {int(dollars)}
 | 
			
		||||
        """)
 | 
			
		||||
        # not sure what to do here. For now just log and do nothing.
 | 
			
		||||
      self.cursor.execute( "DELETE FROM stripe_checkout_sessions WHERE id = %s", (id,) )
 | 
			
		||||
      self.cursor.execute( "DELETE FROM payment_sessions WHERE id = %s AND type = %s", (id, payment_type) )
 | 
			
		||||
      self.cursor.execute( "INSERT INTO payments (email, dollars) VALUES (%s, %s)", (rows[0][0], rows[0][1]) )
 | 
			
		||||
      self.connection.commit()
 | 
			
		||||
      return rows[0][0]
 | 
			
		||||
 | 
			
		||||
@ -18,34 +18,79 @@ from capsulflask.auth import account_required
 | 
			
		||||
 | 
			
		||||
from capsulflask.db import get_model
 | 
			
		||||
 | 
			
		||||
bp = Blueprint("stripe", __name__, url_prefix="/stripe")
 | 
			
		||||
bp = Blueprint("payment", __name__, url_prefix="/payment")
 | 
			
		||||
 | 
			
		||||
@bp.route("/", methods=("GET", "POST"))
 | 
			
		||||
@account_required
 | 
			
		||||
def index():
 | 
			
		||||
 | 
			
		||||
  stripe_checkout_session_id=None
 | 
			
		||||
def validate_dollars():
 | 
			
		||||
  errors = list()
 | 
			
		||||
 | 
			
		||||
  if request.method == "POST":
 | 
			
		||||
    if "dollars" not in request.form:
 | 
			
		||||
      errors.append("dollars is required")
 | 
			
		||||
  dollars = None
 | 
			
		||||
  if "dollars" not in request.form:
 | 
			
		||||
    errors.append("dollars is required")
 | 
			
		||||
  else:
 | 
			
		||||
    dollars = None
 | 
			
		||||
    try:
 | 
			
		||||
      dollars = decimal.Decimal(request.form["dollars"])
 | 
			
		||||
    except:
 | 
			
		||||
      errors.append("dollars must be a number")
 | 
			
		||||
 | 
			
		||||
    if dollars and dollars < decimal.Decimal(1):
 | 
			
		||||
      errors.append("dollars must be >= 1")
 | 
			
		||||
    # TODO re enable this 
 | 
			
		||||
    # if dollars and dollars < decimal.Decimal(1):
 | 
			
		||||
    #   errors.append("dollars must be >= 1")
 | 
			
		||||
 | 
			
		||||
  return [errors, dollars]
 | 
			
		||||
 | 
			
		||||
@bp.route("/btcpay", methods=("GET", "POST"))
 | 
			
		||||
@account_required
 | 
			
		||||
def btcpay_payment():
 | 
			
		||||
  errors = list()
 | 
			
		||||
  invoice_id = None
 | 
			
		||||
 | 
			
		||||
  if request.method == "POST":
 | 
			
		||||
    result = validate_dollars()
 | 
			
		||||
    errors = result[0]
 | 
			
		||||
    dollars = result[1]
 | 
			
		||||
 | 
			
		||||
    if len(errors) == 0:
 | 
			
		||||
      invoice = current_app.config['BTCPAY_CLIENT'].create_invoice(dict(
 | 
			
		||||
        price=float(dollars), 
 | 
			
		||||
        currency="USD",
 | 
			
		||||
        itemDesc="Capsul Cloud Compute",
 | 
			
		||||
        transactionSpeed="high",
 | 
			
		||||
        redirectURL=f"{current_app.config['BASE_URL']}/account-balance"
 | 
			
		||||
      ))
 | 
			
		||||
      # print(invoice)
 | 
			
		||||
      invoice_id = invoice["id"]
 | 
			
		||||
 | 
			
		||||
      print(f"created btcpay invoice_id={invoice_id} ( {session['account']}, ${request.form['dollars']} )")
 | 
			
		||||
 | 
			
		||||
      get_model().create_payment_session("btcpay", invoice_id, session["account"], dollars)
 | 
			
		||||
 | 
			
		||||
      return redirect(invoice["url"])
 | 
			
		||||
 | 
			
		||||
  for error in errors:
 | 
			
		||||
    flash(error)
 | 
			
		||||
 | 
			
		||||
  return render_template("btcpay.html", invoice_id=invoice_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.route("/stripe", methods=("GET", "POST"))
 | 
			
		||||
@account_required
 | 
			
		||||
def stripe_payment():
 | 
			
		||||
 | 
			
		||||
  stripe_checkout_session_id=None
 | 
			
		||||
  errors = list()
 | 
			
		||||
 | 
			
		||||
  if request.method == "POST":
 | 
			
		||||
    result = validate_dollars()
 | 
			
		||||
    errors = result[0]
 | 
			
		||||
    dollars = result[1]
 | 
			
		||||
 | 
			
		||||
    if len(errors) == 0:
 | 
			
		||||
 | 
			
		||||
      print(f"creating stripe checkout session for {session['account']}, ${request.form['dollars']}")
 | 
			
		||||
      print(f"creating stripe checkout session for {session['account']}, ${dollars}")
 | 
			
		||||
 | 
			
		||||
      checkout_session = stripe.checkout.Session.create(
 | 
			
		||||
        success_url=current_app.config['BASE_URL'] + "/stripe/success?session_id={CHECKOUT_SESSION_ID}",
 | 
			
		||||
        cancel_url=current_app.config['BASE_URL'] + "/stripe",
 | 
			
		||||
        success_url=current_app.config['BASE_URL'] + "/payment/stripe/success?session_id={CHECKOUT_SESSION_ID}",
 | 
			
		||||
        cancel_url=current_app.config['BASE_URL'] + "/payment/stripe",
 | 
			
		||||
        payment_method_types=["card"],
 | 
			
		||||
        customer_email=session["account"],
 | 
			
		||||
        line_items=[
 | 
			
		||||
@ -60,9 +105,9 @@ def index():
 | 
			
		||||
      )
 | 
			
		||||
      stripe_checkout_session_id = checkout_session['id']
 | 
			
		||||
 | 
			
		||||
      print(f"stripe_checkout_session_id={stripe_checkout_session_id} ( {session['account']}, ${request.form['dollars']} )")
 | 
			
		||||
      print(f"stripe_checkout_session_id={stripe_checkout_session_id} ( {session['account']}, ${dollars} )")
 | 
			
		||||
 | 
			
		||||
      get_model().create_stripe_checkout_session(stripe_checkout_session_id, session["account"], request.form["dollars"])
 | 
			
		||||
      get_model().create_payment_session("stripe", stripe_checkout_session_id, session["account"], dollars)
 | 
			
		||||
 | 
			
		||||
  for error in errors:
 | 
			
		||||
    flash(error)
 | 
			
		||||
@ -73,11 +118,11 @@ def index():
 | 
			
		||||
    stripe_public_key=current_app.config["STRIPE_PUBLISHABLE_KEY"]
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
@bp.route("/success", methods=("GET",))
 | 
			
		||||
@bp.route("/stripe/success", methods=("GET",))
 | 
			
		||||
def success():
 | 
			
		||||
  stripe_checkout_session_id = request.args.get('session_id')
 | 
			
		||||
  if not stripe_checkout_session_id:
 | 
			
		||||
    print("/stripe/success returned 400: missing required URL parameter session_id")
 | 
			
		||||
    print("/payment/stripe/success returned 400: missing required URL parameter session_id")
 | 
			
		||||
    abort(400, "missing required URL parameter session_id")
 | 
			
		||||
  else:
 | 
			
		||||
    checkout_session_completed_events = stripe.Event.list(
 | 
			
		||||
@ -95,9 +140,9 @@ def success():
 | 
			
		||||
        cents = checkout_session['display_items'][0]['amount']
 | 
			
		||||
        dollars = decimal.Decimal(cents)/100
 | 
			
		||||
 | 
			
		||||
        #consume_stripe_checkout_session deletes the checkout session row and inserts a payment row
 | 
			
		||||
        # its ok to call consume_stripe_checkout_session more than once because it only takes an action if the session exists
 | 
			
		||||
        success_account = get_model().consume_stripe_checkout_session(stripe_checkout_session_id, dollars)
 | 
			
		||||
        #consume_payment_session deletes the checkout session row and inserts a payment row
 | 
			
		||||
        # its ok to call consume_payment_session more than once because it only takes an action if the session exists
 | 
			
		||||
        success_account = get_model().consume_payment_session(stripe_checkout_session_id, dollars)
 | 
			
		||||
        
 | 
			
		||||
        if success_account:
 | 
			
		||||
          print(f"{success_account} paid ${dollars} successfully (stripe_checkout_session_id={stripe_checkout_session_id})")
 | 
			
		||||
@ -121,17 +166,17 @@ def success():
 | 
			
		||||
#       dollars = event['data']['object']['display_items'][0]['amount']
 | 
			
		||||
#       stripe_checkout_session_id = event['data']['object']['id']
 | 
			
		||||
 | 
			
		||||
#       #consume_stripe_checkout_session deletes the checkout session row and inserts a payment row
 | 
			
		||||
#       # its ok to call consume_stripe_checkout_session more than once because it only takes an action if the session exists
 | 
			
		||||
#       success_account = get_model().consume_stripe_checkout_session(stripe_checkout_session_id, dollars)
 | 
			
		||||
#       #consume_payment_session deletes the checkout session row and inserts a payment row
 | 
			
		||||
#       # its ok to call consume_payment_session more than once because it only takes an action if the session exists
 | 
			
		||||
#       success_account = get_model().consume_payment_session(stripe_checkout_session_id, dollars)
 | 
			
		||||
#       if success_account:
 | 
			
		||||
#         print(f"{success_account} paid ${dollars} successfully (stripe_checkout_session_id={stripe_checkout_session_id})")
 | 
			
		||||
    
 | 
			
		||||
#     return jsonify({'status': 'success'})
 | 
			
		||||
#   except ValueError as e:
 | 
			
		||||
#     print("/stripe/webhook returned 400: bad request", e)
 | 
			
		||||
#     print("/payment/stripe/webhook returned 400: bad request", e)
 | 
			
		||||
#     abort(400, "bad request")
 | 
			
		||||
#   except stripe.error.SignatureVerificationError:
 | 
			
		||||
#     print("/stripe/webhook returned 400: invalid signature")
 | 
			
		||||
#     print("/payment/stripe/webhook returned 400: invalid signature")
 | 
			
		||||
#     abort(400, "invalid signature")
 | 
			
		||||
      
 | 
			
		||||
@ -1,12 +0,0 @@
 | 
			
		||||
from flask import Blueprint
 | 
			
		||||
from flask import render_template
 | 
			
		||||
 | 
			
		||||
from capsulflask.db import get_model
 | 
			
		||||
from capsulflask.auth import account_required
 | 
			
		||||
 | 
			
		||||
bp = Blueprint("btcpay", __name__, url_prefix="/btcpay")
 | 
			
		||||
 | 
			
		||||
@bp.route("/")
 | 
			
		||||
@account_required
 | 
			
		||||
def index():
 | 
			
		||||
  return render_template("btcpay.html")
 | 
			
		||||
@ -52,6 +52,7 @@ CREATE TABLE payments (
 | 
			
		||||
  email      TEXT REFERENCES accounts(email) ON DELETE RESTRICT,
 | 
			
		||||
  created    TIMESTAMP NOT NULL DEFAULT NOW(),
 | 
			
		||||
  dollars    NUMERIC(8, 2) NOT NULL,
 | 
			
		||||
  invalidated BOOLEAN NOT NULL DEFAULT FALSE,
 | 
			
		||||
  PRIMARY KEY (email, created)
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
@ -62,13 +63,21 @@ CREATE TABLE login_tokens (
 | 
			
		||||
  PRIMARY KEY (email, created)
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE stripe_checkout_sessions (
 | 
			
		||||
CREATE TABLE payment_sessions (
 | 
			
		||||
  id         TEXT PRIMARY KEY,
 | 
			
		||||
  email      TEXT REFERENCES accounts(email) ON DELETE RESTRICT,
 | 
			
		||||
  type       TEXT NOT NULL,
 | 
			
		||||
  created    TIMESTAMP NOT NULL DEFAULT NOW(),
 | 
			
		||||
  dollars    NUMERIC(8, 2) NOT NULL
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE unconfirmed_btcpay_invoices (
 | 
			
		||||
  id         TEXT PRIMARY KEY,
 | 
			
		||||
  email      TEXT REFERENCES accounts(email) ON DELETE RESTRICT,
 | 
			
		||||
  created    TIMESTAMP NOT NULL,
 | 
			
		||||
  FOREIGN KEY (email, created) REFERENCES payments(email, created) ON DELETE CASCADE
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
INSERT INTO os_images (id, template_image_file_name, description)
 | 
			
		||||
VALUES  ('alpine311', 'alpine-cloud-2020-04-18.qcow2', 'Alpine Linux 3.11'),
 | 
			
		||||
        ('ubuntu18', 'ubuntu-18.04-minimal-cloudimg-amd64.img', 'Ubuntu 18.04 LTS (Bionic)'),
 | 
			
		||||
 | 
			
		||||
@ -226,6 +226,9 @@ th {
 | 
			
		||||
td {
 | 
			
		||||
  border-bottom: 2px dotted #777e7355;
 | 
			
		||||
}
 | 
			
		||||
.invalidated {
 | 
			
		||||
  text-decoration: line-through;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.waiting-pulse {
 | 
			
		||||
  animation: waiting-pulse 1s ease-in-out 0s infinite forwards alternate;
 | 
			
		||||
 | 
			
		||||
@ -25,8 +25,8 @@
 | 
			
		||||
        <tbody>
 | 
			
		||||
          {% for payment in payments %}
 | 
			
		||||
            <tr>
 | 
			
		||||
              <td>${{ payment["dollars"] }}</td>
 | 
			
		||||
              <td>{{ payment["created"] }}</td>
 | 
			
		||||
              <td class="{{ payment['class_name'] }}">${{ payment["dollars"] }}</td>
 | 
			
		||||
              <td class="{{ payment['class_name'] }}">{{ payment["created"] }}</td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
        </tbody>
 | 
			
		||||
@ -38,10 +38,10 @@
 | 
			
		||||
        <h1>PAYMENT OPTIONS</h1>
 | 
			
		||||
        <ul>
 | 
			
		||||
          <li>
 | 
			
		||||
            <a href="/stripe">Add funds with Credit/Debit (stripe)</a>
 | 
			
		||||
            <a href="/payment/stripe">Add funds with Credit/Debit (stripe)</a>
 | 
			
		||||
            <ul><li>notice: stripe will load nonfree javascript </li></ul>
 | 
			
		||||
          </li>
 | 
			
		||||
          <li><a href="/btcpay">Add funds with Bitcoin/Litecoin/Monero (btcpay)</a></li>
 | 
			
		||||
          <li><a href="/payment/btcpay">Add funds with Bitcoin/Litecoin/Monero (btcpay)</a></li>
 | 
			
		||||
    
 | 
			
		||||
          <li>Cash: email treasurer@cyberia.club</li>
 | 
			
		||||
        </ul>
 | 
			
		||||
 | 
			
		||||
@ -10,18 +10,21 @@
 | 
			
		||||
</div>
 | 
			
		||||
<div class="row half-margin">
 | 
			
		||||
  
 | 
			
		||||
  <form method="POST" action="https://btcpay.cyberia.club/api/v1/invoices">
 | 
			
		||||
    <input type="hidden" name="storeId" value="FgYNGKEHKm2tBhwejo1zdSQ15DknPWvip2pXLKBv96wc">
 | 
			
		||||
    <input type="hidden" name="currency" value="USD">
 | 
			
		||||
  <form method="POST" action="/payment/btcpay">
 | 
			
		||||
    <div class="row">
 | 
			
		||||
      <label for="btcpay-input-price">$</label>
 | 
			
		||||
      <input 
 | 
			
		||||
      <!--TODO-- <input 
 | 
			
		||||
        id="btcpay-input-price" 
 | 
			
		||||
        name="price" 
 | 
			
		||||
        type="number" 
 | 
			
		||||
        min="0" 
 | 
			
		||||
        max="2000" 
 | 
			
		||||
        oninput="event.preventDefault();isNaN(event.target.value) || event.target.value <= 0 ? document.querySelector('#btcpay-input-price').value = 0 : event.target.value"
 | 
			
		||||
      />-->
 | 
			
		||||
      <input 
 | 
			
		||||
        id="btcpay-input-price" 
 | 
			
		||||
        name="dollars" 
 | 
			
		||||
        type="text" 
 | 
			
		||||
      />
 | 
			
		||||
      <input type="image" class="submit" name="submit" 
 | 
			
		||||
        src="https://btcpay.cyberia.club/img/paybutton/pay.svg" 
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user