diff --git a/Pipfile b/Pipfile index b1df5d8..2d0e3f2 100644 --- a/Pipfile +++ b/Pipfile @@ -24,6 +24,7 @@ toml = "==0.10.0" typed-ast = "==1.4.1" Werkzeug = "==1.0.1" wrapt = "==1.12.1" +stripe = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index 1c18ada..062d3fe 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "9b88525881f174f421ccb5b49fc34948053fb5d9d9eafa90bdec8ef0bfbc30ea" + "sha256": "8355b0bc9024432220ab4f05b2997f827af534691520249c6ff8bb2db9014dc8" }, "pipfile-spec": 6, "requires": { @@ -31,6 +31,20 @@ "index": "pypi", "version": "==1.4" }, + "certifi": { + "hashes": [ + "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", + "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" + ], + "version": "==2020.4.5.1" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, "click": { "hashes": [ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", @@ -62,6 +76,13 @@ "index": "pypi", "version": "==20.0.4" }, + "idna": { + "hashes": [ + "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", + "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" + ], + "version": "==2.9" + }, "isort": { "hashes": [ "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", @@ -195,6 +216,14 @@ "index": "pypi", "version": "==2.5.2" }, + "requests": { + "hashes": [ + "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", + "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" + ], + "markers": "python_version >= '3.0'", + "version": "==2.23.0" + }, "six": { "hashes": [ "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", @@ -203,6 +232,14 @@ "index": "pypi", "version": "==1.14.0" }, + "stripe": { + "hashes": [ + "sha256:515fe2cc915e639468f30150a39c162fc0fb090256ae9d6a04e5022925d136f1", + "sha256:bdbbea632b8faa983c670db61debbe0bdb5802ef98fd0613a03aa466e56cdade" + ], + "index": "pypi", + "version": "==2.48.0" + }, "toml": { "hashes": [ "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", @@ -240,6 +277,13 @@ "markers": "implementation_name == 'cpython' and python_version < '3.8'", "version": "==1.4.1" }, + "urllib3": { + "hashes": [ + "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", + "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" + ], + "version": "==1.25.9" + }, "werkzeug": { "hashes": [ "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", diff --git a/capsulflask/__init__.py b/capsulflask/__init__.py index e04396e..1a8be88 100644 --- a/capsulflask/__init__.py +++ b/capsulflask/__init__.py @@ -1,3 +1,4 @@ +import stripe import os from dotenv import load_dotenv, find_dotenv @@ -23,11 +24,15 @@ app.config.from_mapping( MAIL_PASSWORD=os.environ.get("MAIL_PASSWORD", default=""), MAIL_DEFAULT_SENDER=os.environ.get("MAIL_DEFAULT_SENDER", default="forest@nullhex.com"), - STRIPE_API_VERSION=os.environ.get("STRIPE_API_VERSION", default=""), + STRIPE_API_VERSION=os.environ.get("STRIPE_API_VERSION", default="2020-03-02"), STRIPE_SECRET_KEY=os.environ.get("STRIPE_SECRET_KEY", default=""), STRIPE_PUBLISHABLE_KEY=os.environ.get("STRIPE_PUBLISHABLE_KEY", default="") + #STRIPE_WEBHOOK_SECRET=os.environ.get("STRIPE_WEBHOOK_SECRET", default="") ) +stripe.api_key = app.config['STRIPE_SECRET_KEY'] +stripe.api_version = app.config['STRIPE_API_VERSION'] + app.config['FLASK_MAIL_INSTANCE'] = Mail(app) app.config['VIRTUALIZATION_MODEL'] = virt_model.MockVirtualization() @@ -35,11 +40,12 @@ from capsulflask import db db.init_app(app) -from capsulflask import auth, landing, console +from capsulflask import auth, landing, console, stripe app.register_blueprint(landing.bp) app.register_blueprint(auth.bp) app.register_blueprint(console.bp) +app.register_blueprint(stripe.bp) app.add_url_rule("/", endpoint="index") diff --git a/capsulflask/console.py b/capsulflask/console.py index 322b1e4..cf46c5a 100644 --- a/capsulflask/console.py +++ b/capsulflask/console.py @@ -5,8 +5,6 @@ from flask import Blueprint from flask import flash from flask import current_app from flask import g -from flask import redirect -from flask import url_for from flask import request from flask import session from flask import render_template diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py index 3c8c6af..86c187f 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -23,7 +23,7 @@ class DBModel: return token def consume_token(self, token): - self.cursor.execute("SELECT email FROM login_tokens WHERE token = %s", (token, )) + self.cursor.execute("SELECT email FROM login_tokens WHERE token = %s and created > (NOW() - INTERVAL '20 min')", (token, )) rows = self.cursor.fetchall() if len(rows) > 0: email = rows[0][0] @@ -150,4 +150,35 @@ class DBModel: lambda x: dict(dollars=x[0], created=x[1]), self.cursor.fetchall() )) + + def create_stripe_checkout_session(self, id, email, dollars): + self.cursor.execute(""" + INSERT INTO stripe_checkout_sessions (id, email, dollars) + VALUES (%s, %s, %d) + """, + (id, 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,)) + 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} + account: {rows[0][0]} + Recorded amount: {int(rows[0][1])} + Stripe 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( "INSERT INTO payments (email, dollars) VALUES (%s, %d)", (rows[0][0], rows[0][1]) ) + self.connection.commit() + return rows[0][0] + else: + return None + + diff --git a/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql b/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql index 8c344c1..00f1ba2 100644 --- a/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql +++ b/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql @@ -62,6 +62,13 @@ CREATE TABLE login_tokens ( PRIMARY KEY (email, created) ); +CREATE TABLE stripe_checkout_sessions ( + id TEXT PRIMARY KEY, + email TEXT REFERENCES accounts(email) ON DELETE RESTRICT, + created TIMESTAMP NOT NULL DEFAULT NOW(), + dollars NUMERIC(8, 2) NOT NULL +); + 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 Beaver)'), diff --git a/capsulflask/static/capsul-product-image.png b/capsulflask/static/capsul-product-image.png new file mode 100644 index 0000000..fd6dc19 Binary files /dev/null and b/capsulflask/static/capsul-product-image.png differ diff --git a/capsulflask/static/style.css b/capsulflask/static/style.css index c07779e..73a64c9 100644 --- a/capsulflask/static/style.css +++ b/capsulflask/static/style.css @@ -122,7 +122,7 @@ select { padding-right: 2em; } -input[type=text], textarea { +input[type=text], input[type=number], textarea { font: calc(0.40rem + 1vmin) monospace; border: 1px solid #777e73; outline: 0; @@ -152,6 +152,17 @@ input[type=submit], select { cursor: pointer; } +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Firefox */ +input[type=number] { + -moz-appearance: textfield; +} + h1, h2, h3, h4, h5 { font-size:calc(0.40rem + 1vmin); margin: initial; @@ -195,7 +206,7 @@ td { .code { display: inline-block; - padding: 0.5em 2em; + padding: 0.5em 1.2em; border-radius: 0.5em; border: 1px solid #777e73; background: #bdc7b810; diff --git a/capsulflask/stripe.py b/capsulflask/stripe.py index 4d60ad0..84e4c70 100644 --- a/capsulflask/stripe.py +++ b/capsulflask/stripe.py @@ -1 +1,116 @@ -import stripe \ No newline at end of file +import stripe +import json +import decimal + + +from flask import Blueprint +from flask import request +from flask import current_app +from flask import session +from flask import redirect +from flask import url_for +from flask import jsonify +from flask import flash +from flask import render_template +from werkzeug.exceptions import abort + +from capsulflask.auth import account_required + +from capsulflask.db import get_model + +bp = Blueprint("stripe", __name__, url_prefix="/stripe") + +@bp.route("/", methods=("GET", "POST")) +@account_required +def index(): + + stripe_checkout_session_id=None + + if request.method == "POST": + errors = list() + if "dollars" not in request.form: + errors.append("dollars is required") + elif decimal.Decimal(request.form["dollars"]) < decimal.Decimal(1): + errors.append("dollars must be >= 1") + + if len(errors) == 0: + + print(f"creating stripe checkout session for {session['account']}, ${request.form['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", + payment_method_types=["card"], + customer_email=session["account"], + line_items=[ + { + "name": "Capsul Cloud Compute", + "images": [current_app.config['BASE_URL']+"/static/capsul-product-image.png"], + "quantity": 1, + "currency": "usd", + "amount": request.form["dollars"] + } + ] + ) + stripe_checkout_session_id = checkout_session['id'] + + print(f"stripe_checkout_session_id={stripe_checkout_session_id} ( {session['account']}, ${request.form['dollars']} )") + + get_model().create_stripe_checkout_session(stripe_checkout_session_id, session["account"], request.form["dollars"]) + + for error in errors: + flash(error) + + return render_template( + "stripe.html", + stripe_checkout_session_id=stripe_checkout_session_id, + stripe_public_key=current_app.config["STRIPE_PUBLISHABLE_KEY"] + ) + +@bp.route("/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") + abort(400, "missing required URL parameter session_id") + else: + checkout_session = stripe.checkout.Session.retrieve(stripe_checkout_session_id) + if checkout_session and 'display_items' in checkout_session: + dollars = checkout_session['display_items'][0]['amount'] + + #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) + if success_account: + print(f"{success_account} paid ${dollars} successfully (stripe_checkout_session_id={stripe_checkout_session_id})") + + return redirect(url_for("console.account_balance")) + +# I don't think the webhook is needed +# @bp.route("/webhook", methods=("POST",)) +# def webhook(): + +# request_data = json.loads(request.data) +# signature = request.headers.get('stripe-signature') +# try: +# event = stripe.Webhook.construct_event( +# payload=request_data, +# sig_header=signature, +# secret=current_app.config['STRIPE_WEBHOOK_SECRET'] +# ) +# if event['type'] == 'checkout.session.completed': +# 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 +# get_model().consume_stripe_checkout_session(stripe_checkout_session_id, dollars) + +# return jsonify({'status': 'success'}) +# except ValueError as e: +# print("/stripe/webhook returned 400: bad request", e) +# abort(400, "bad request") +# except stripe.error.SignatureVerificationError: +# print("/stripe/webhook returned 400: invalid signature") +# abort(400, "invalid signature") + \ No newline at end of file diff --git a/capsulflask/templates/account-balance.html b/capsulflask/templates/account-balance.html index 69779ff..4062f8f 100644 --- a/capsulflask/templates/account-balance.html +++ b/capsulflask/templates/account-balance.html @@ -38,10 +38,10 @@

PAYMENT OPTIONS

diff --git a/capsulflask/templates/base.html b/capsulflask/templates/base.html index 90df517..2b7b57c 100644 --- a/capsulflask/templates/base.html +++ b/capsulflask/templates/base.html @@ -5,6 +5,7 @@ + {% block head %}{% endblock %} diff --git a/capsulflask/templates/stripe.html b/capsulflask/templates/stripe.html new file mode 100644 index 0000000..df51f53 --- /dev/null +++ b/capsulflask/templates/stripe.html @@ -0,0 +1,39 @@ +{% extends 'base.html' %} + +{% block title %}Capsuls{% endblock %} + +{% block head %}{% endblock %} + +{% block content %} +
+

PAY WITH STRIPE

+
+
+
+
+ + +
+
+ +
+
+
+ +{% if stripe_checkout_session_id %} + +{% endif %} + +{% endblock %} + +{% block pagesource %}/templates/stripe.html{% endblock %}