Skip to content

Commit deeee3c

Browse files
committed
Subscription madness.
1 parent defbfd3 commit deeee3c

File tree

10 files changed

+278
-6
lines changed

10 files changed

+278
-6
lines changed

Dockerfile

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ RUN apk add --update postgresql-dev
44
ADD . /code
55
WORKDIR /code
66
RUN pip install -r requirements.txt
7-
CMD ["python", "serve_debug.py"]
7+
ENV FLASK_APP=auth FLASK_ENV=development
8+
CMD ["flask", "run", "-h", "0.0.0.0"]

auth/__init__.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
1-
from flask import Flask, render_template, g
1+
from flask import Flask, render_template
22
from flask_login import login_required, current_user
33
from flask_sslify import SSLify
4+
from flask_wtf import CSRFProtect
45

5-
from .models import init_app as init_db
6+
from .models import init_app as init_db, db
67
from .login import init_app as init_login
78
from .login.pebble import ensure_pebble, pebble
89
from .oauth import init_app as init_oauth
910
from .api import init_app as init_api
1011
from .redis import init_app as init_redis
12+
from .billing import init_app as init_billing
1113

1214
from .settings import config
1315

14-
1516
app = Flask(__name__)
17+
CSRFProtect(app)
1618
app.config.update(**config)
1719
sslify = SSLify(app)
1820
if not app.debug:
@@ -21,6 +23,7 @@
2123
init_redis(app)
2224
init_login(app)
2325
init_oauth(app)
26+
init_billing(app)
2427
init_api(app)
2528

2629

auth/api.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
@api.route('/me')
99
@oauth.require_oauth('profile')
1010
def me():
11-
return jsonify(uid=request.oauth.user.id, name=request.oauth.user.name)
11+
return jsonify(
12+
uid=request.oauth.user.id,
13+
name=request.oauth.user.name,
14+
is_subscribed=request.oauth.user.has_active_sub,
15+
)
1216

1317

1418
@api.route('/me/pebble/auth')
@@ -34,3 +38,4 @@ def pebble_dev_portal_me():
3438

3539
def init_app(app, url_prefix='/api/v1'):
3640
app.register_blueprint(api, url_prefix=url_prefix)
41+
app.extensions['csrf'].exempt(api)

auth/billing/__init__.py

+127
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import datetime
2+
3+
import stripe
4+
from flask import Blueprint, render_template, request, redirect, url_for
5+
from flask_login import login_required, current_user
6+
7+
from auth import db
8+
from auth.models import User
9+
from auth.settings import config
10+
11+
stripe.api_key = config['STRIPE_SECRET_KEY']
12+
billing_blueprint = Blueprint("billing", __name__)
13+
14+
15+
def format_ts(value, format='%d-%m-%Y'):
16+
return datetime.datetime.utcfromtimestamp(value).strftime(format)
17+
18+
19+
@billing_blueprint.route('/account')
20+
@login_required
21+
def account_info():
22+
subscription = None
23+
if current_user.stripe_subscription_id:
24+
subscription = stripe.Subscription.retrieve(current_user.stripe_subscription_id)
25+
return render_template('account-info.html', user=current_user, subscription=subscription)
26+
27+
28+
def handle_card_error(e: stripe.error.CardError):
29+
return str(e)
30+
31+
32+
@billing_blueprint.route('/account/sub/create', methods=['POST'])
33+
@login_required
34+
def create_subscription():
35+
plan = request.form['plan']
36+
customer = None
37+
if current_user.stripe_customer_id:
38+
customer = stripe.Customer.retrieve(current_user.stripe_customer_id)
39+
if not customer.deleted:
40+
customer.source = request.form['stripeToken']
41+
try:
42+
customer.save()
43+
except stripe.error.CardError as e:
44+
return handle_card_error(e)
45+
else:
46+
customer = None
47+
if customer is None:
48+
try:
49+
customer = stripe.Customer.create(
50+
description=f"{current_user.name or '(no name)'} (#{current_user.id})",
51+
email=f"{current_user.email}",
52+
source=request.form['stripeToken'],
53+
metadata={
54+
'user_id': current_user.id,
55+
}
56+
)
57+
except stripe.error.CardError as e:
58+
return handle_card_error(e)
59+
current_user.stripe_customer_id = customer.stripe_id
60+
start_date = None
61+
to_cancel = None
62+
if current_user.stripe_subscription_id:
63+
sub = stripe.Subscription.retrieve(current_user.stripe_subscription_id)
64+
if sub.status != "canceled":
65+
# In this case we have an active subscription and are changing the billing
66+
# frequency. We need to delete the old item and create a new one.
67+
to_cancel = sub
68+
start_date = sub.current_period_end
69+
try:
70+
sub = stripe.Subscription.create(
71+
customer=customer.stripe_id,
72+
items=[{"plan": plan}],
73+
trial_end=start_date,
74+
)
75+
except stripe.error.CardError as e:
76+
return handle_card_error(e)
77+
current_user.stripe_subscription_id = sub.stripe_id
78+
current_user.subscription_expiry = datetime.datetime.utcfromtimestamp(sub.current_period_end).replace(tzinfo=datetime.timezone.utc) + datetime.timedelta(days=1)
79+
db.session.commit()
80+
if to_cancel:
81+
to_cancel.delete()
82+
return redirect(url_for('.account_info'))
83+
84+
85+
@billing_blueprint.route('/account/sub/delete', methods=["POST"])
86+
def cancel_subscription():
87+
sub = stripe.Subscription.retrieve(current_user.stripe_subscription_id)
88+
sub.delete(at_period_end=True)
89+
return redirect(url_for('.account_info'))
90+
91+
92+
@billing_blueprint.route('/stripe/event', methods=["POST"])
93+
def stripe_event():
94+
payload = request.data
95+
signature = request.headers['Stripe-Signature']
96+
try:
97+
event = stripe.Webhook.construct_event(payload, signature, config['STRIPE_WEBHOOK_KEY'])
98+
except ValueError:
99+
return '???', 400
100+
except stripe.error.SignatureVerificationError:
101+
return 'signature verification failed', 400
102+
103+
if event.type == "invoice.payment_succeeded":
104+
# try and figure out if we care about this.
105+
results = []
106+
for line in event.data.object.lines.data:
107+
if line.subscription:
108+
user = User.query.filter_by(stripe_subscription_id=line.subscription).one_or_none()
109+
if user is not None:
110+
user.subscription_expiry = datetime.datetime.utcfromtimestamp(line.period.end) + datetime.timedelta(days=1)
111+
db.session.commit()
112+
results.append(f"Set expiry date for user #{user.id} to {user.subscription_expiry}.")
113+
return '\n'.join(results)
114+
elif event.type == "customer.subscription.deleted":
115+
user = User.query.filter_by(stripe_subscription_id=event.data.object.id).one_or_none()
116+
if user is not None:
117+
user.subscription_expiry = None
118+
db.session.commit()
119+
return f'Terminated subscription for user #{user.id}.'
120+
121+
return ''
122+
123+
124+
def init_app(app, prefix='/'):
125+
app.register_blueprint(billing_blueprint, url_prefix=prefix)
126+
app.jinja_env.filters['format_ts'] = format_ts
127+
app.extensions['csrf'].exempt(stripe_event)

auth/login/base.py

+1
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,4 @@ def init_app(app):
8888
login_manager.login_view = 'login.login'
8989
login_manager.init_app(app)
9090
auth.init_app(app)
91+
app.extensions['csrf'].exempt(login_blueprint)

auth/models.py

+9
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import datetime
12
from flask_sqlalchemy import SQLAlchemy
23
from flask_migrate import Migrate
34
from flask_login import UserMixin
@@ -6,6 +7,7 @@
67
db = SQLAlchemy()
78
migrate = Migrate()
89

10+
911
class User(UserMixin, db.Model):
1012
__tablename__ = "users"
1113
id = db.Column(db.Integer, primary_key=True)
@@ -16,6 +18,13 @@ class User(UserMixin, db.Model):
1618
pebble_token = db.Column(db.String, nullable=True, index=True)
1719
has_logged_in = db.Column(db.Boolean, nullable=False, server_default='false')
1820
account_type = db.Column(db.Integer, nullable=False, server_default='0')
21+
stripe_customer_id = db.Column(db.String, nullable=True, index=True)
22+
stripe_subscription_id = db.Column(db.String, nullable=True, index=True)
23+
subscription_expiry = db.Column(db.DateTime, nullable=True)
24+
25+
@property
26+
def has_active_sub(self):
27+
return self.subscription_expiry is not None and datetime.datetime.utcnow() <= self.subscription_expiry
1928

2029

2130
class UserIdentity(db.Model):

auth/oauth.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from datetime import datetime, timedelta
2+
import logging
23

3-
from flask import Blueprint, abort, render_template, request
4+
from flask import Blueprint, abort, render_template, request, redirect
45
from flask_oauthlib.provider import OAuth2Provider
56
from oauthlib.common import generate_token as generate_random_token
67
from flask_login import current_user, login_required
78

9+
from auth.login.base import demand_pebble
810
from .models import db, IssuedToken, AuthClient, User
911
from .redis import client as redis
1012
import json
@@ -52,6 +54,7 @@ def load_grant(client_id, code):
5254
@oauth.grantsetter
5355
def set_grant(client_id, code, request, *args, **kwargs):
5456
if not current_user.is_authenticated:
57+
logging.error("Tried to set a grant for a user who is not logged in!?")
5558
return None
5659
grant = Grant(client_id, code['code'], current_user.id, request.scopes, request.redirect_uri)
5760
redis.setex(grant.key, 100, grant.serialise())

auth/settings.py

+3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
'consumer_key': environ['FACEBOOK_CONSUMER_KEY'],
2222
'consumer_secret': environ['FACEBOOK_CONSUMER_SECRET'],
2323
},
24+
'STRIPE_SECRET_KEY': environ['STRIPE_SECRET_KEY'],
25+
'STRIPE_PUBLIC_KEY': environ['STRIPE_PUBLIC_KEY'],
26+
'STRIPE_WEBHOOK_KEY': environ.get('STRIPE_WEBHOOK_KEY'),
2427
}
2528

2629
if 'PEBBLE_CONSUMER_KEY' in environ and 'PEBBLE_CONSUMER_SECRET' in environ:

auth/templates/account-info.html

+118
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
{% extends "base.html" %}
2+
{% block body %}
3+
<style>
4+
table.actually-a-table {
5+
border-collapse: collapse;
6+
}
7+
table.actually-a-table tr {
8+
border-bottom: 1px solid #dbdadb;
9+
}
10+
table.actually-a-table tr:last-child {
11+
border: 0;
12+
}
13+
</style>
14+
<h2>Rebble Account</h2>
15+
<table class="actually-a-table">
16+
<tr>
17+
<td>Account name</td>
18+
<td>{% if user.name %}{{ user.name }}{% else %}<em>None</em>{% endif %}</td>
19+
</tr>
20+
<tr>
21+
<td>Email address</td>
22+
<td>{{ user.email }}</td>
23+
</tr>
24+
<tr>
25+
<td>Linked pebble account</td>
26+
<td>{% if user.pebble_token %}Yes{% else %}No{% endif %}</td>
27+
</tr>
28+
<tr>
29+
<td style="padding-right: 10px;">Voice / Weather subscription</td>
30+
<td>
31+
{% if subscription %}
32+
{% if subscription.status == 'past_due' %}
33+
<strong>Overdue</strong>,
34+
{% elif subscription.status in ('active', 'trialing') %}
35+
<strong>Active</strong>, auto-renew
36+
{% if subscription.cancel_at_period_end %}
37+
disabled.<br>Subscription ends on {{ subscription.current_period_end | format_ts }}.
38+
{% else %}
39+
enabled.<br>Renews for another {{ subscription.plan.interval }} on {{ subscription.current_period_end | format_ts }}.
40+
{% endif %}
41+
{% else %}
42+
Expired on {{ subscription.ended_at | format_ts }}.
43+
{% endif %}
44+
{% else %}
45+
None
46+
{% endif %}
47+
</td>
48+
</tr>
49+
</table>
50+
<hr>
51+
<h3>Rebble Subscriptions</h3>
52+
{% if not subscription or subscription.status == 'canceled' or subscription.cancel_at_period_end %}
53+
<p>
54+
Subscribing to Rebble for just $3/month enables dictation and weather support on your account,
55+
and helps us keep the service running for everyone!
56+
</p>
57+
<p>
58+
We see you do not have an active subscription, or your subscription is not set to auto-renew. Want to
59+
subscribe? We offer two subscription options:
60+
</p>
61+
<table style="width: 100%; text-align: center;">
62+
<tr>
63+
<td style="vertical-align: top;">
64+
Pay $3.00 / month
65+
<form action="{{ url_for('.create_subscription') }}" method="POST">
66+
<script
67+
src="https://checkout.stripe.com/checkout.js" class="stripe-button"
68+
data-key="pk_test_HtWIJSiwQRLunBIXRSpiHk85"
69+
data-email="{{ user.email }}"
70+
data-image="//static.rebble.io/auth/logo-128x128.png"
71+
data-label="Subscribe Monthly"
72+
data-name="Rebble Alliance"
73+
data-allow-remember-me="false"
74+
data-description="Voice / Dictation Subscription"
75+
data-locale="auto"
76+
data-zip-code="true"
77+
data-panel-label="Subscribe Monthly"
78+
>
79+
</script>
80+
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
81+
<input type="hidden" name="plan" value="plan_D8zMUHWw0uZfN9">
82+
</form>
83+
</td>
84+
<td style="vertical-align: top;">Annually at $33/year
85+
<form action="{{ url_for('.create_subscription') }}" method="POST">
86+
<script
87+
src="https://checkout.stripe.com/checkout.js" class="stripe-button"
88+
data-key="pk_test_HtWIJSiwQRLunBIXRSpiHk85"
89+
data-email="{{ user.email }}"
90+
data-image="//static.rebble.io/auth/logo-128x128.png"
91+
data-label="Subscribe Annually"
92+
data-allow-remember-me="false"
93+
data-name="Rebble Alliance"
94+
data-description="Voice / Dictation Subscription"
95+
data-locale="auto"
96+
data-zip-code="true"
97+
data-panel-label="Subscribe Annually"
98+
>
99+
</script>
100+
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
101+
<input type="hidden" name="plan" value="plan_D8zMJGYcyqz29L">
102+
</form>
103+
(One month free!)</td>
104+
</tr>
105+
</table>
106+
<p>We use Stripe to handle our payments, and we never see or store your payment information.</p>
107+
{% else %}
108+
<p>
109+
You are currently subscribed! If you like, you can cancel your subscription.
110+
You will retain your subscription benefits until your subscription lapses, on
111+
{{ subscription.current_period_end | format_ts }}.
112+
</p>
113+
<form action="{{ url_for('.cancel_subscription') }}" method="POST" style="text-align: center;">
114+
<input type="submit" value="Unsubscribe :(" style="font-size: 20px;">
115+
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
116+
</form>
117+
{% endif %}
118+
{% endblock %}

requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Flask-Migrate==2.1.1
1111
Flask-OAuthlib==0.9.4
1212
Flask-SQLAlchemy==2.3.2
1313
Flask-SSLify==0.1.5
14+
flask-wtf==0.14.2
1415
idna==2.6
1516
itsdangerous==0.24
1617
Jinja2==2.10
@@ -30,3 +31,4 @@ SQLAlchemy==1.2.2
3031
stripe==1.82.2
3132
urllib3==1.22
3233
Werkzeug==0.14.1
34+
WTForms==2.2.1

0 commit comments

Comments
 (0)