Merge pull request #347 from simple-login/coinbase
Coinbase integration
This commit is contained in:
commit
d161ca94f6
|
@ -358,3 +358,11 @@ PGP_SIGNER = os.environ.get("PGP_SIGNER")
|
||||||
|
|
||||||
# emails that have empty From address is sent from this special reverse-alias
|
# emails that have empty From address is sent from this special reverse-alias
|
||||||
NOREPLY = os.environ.get("NOREPLY", f"noreply@{EMAIL_DOMAIN}")
|
NOREPLY = os.environ.get("NOREPLY", f"noreply@{EMAIL_DOMAIN}")
|
||||||
|
|
||||||
|
COINBASE_WEBHOOK_SECRET = os.environ.get("COINBASE_WEBHOOK_SECRET")
|
||||||
|
COINBASE_CHECKOUT_ID = os.environ.get("COINBASE_CHECKOUT_ID")
|
||||||
|
COINBASE_API_KEY = os.environ.get("COINBASE_API_KEY")
|
||||||
|
try:
|
||||||
|
COINBASE_YEARLY_PRICE = float(os.environ["COINBASE_YEARLY_PRICE"])
|
||||||
|
except Exception:
|
||||||
|
COINBASE_YEARLY_PRICE = 30.00
|
||||||
|
|
36
app/dashboard/templates/dashboard/extend_subscription.html
Normal file
36
app/dashboard/templates/dashboard/extend_subscription.html
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
{% extends 'default.html' %}
|
||||||
|
|
||||||
|
{% set active_page = "dashboard" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
Extend Subscription
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block default_content %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h1 class="h2">Extend Subscription</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Your subscription is expired on {{ coinbase_subscription.end_at.format("YYYY-MM-DD") }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a class="buy-with-crypto" data-custom="{{ current_user.id }}"
|
||||||
|
href="{{ coinbase_url }}">
|
||||||
|
Extend for 1 yearly - $30
|
||||||
|
</a>
|
||||||
|
<script src="https://commerce.coinbase.com/v1/checkout.js?version=201807">
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
Your subscription will be extended when the payment is confirmed and we'll send you a confirmation email. <br>
|
||||||
|
Please note that it can take up to 1h for processing a cryptocurrency payment.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
|
@ -54,7 +54,7 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="small-text">More information is available on our <a href="https://simplelogin.io/pricing" target="_blank" rel="noopener">Pricing
|
<div class="small-text">More information on our <a href="https://simplelogin.io/pricing" target="_blank" rel="noopener">Pricing
|
||||||
Page <i class="fe fe-external-link"></i>
|
Page <i class="fe fe-external-link"></i>
|
||||||
</a></div>
|
</a></div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -64,7 +64,8 @@
|
||||||
<div class="col-sm-6 col-lg-6">
|
<div class="col-sm-6 col-lg-6">
|
||||||
<div class="display-6 my-3">
|
<div class="display-6 my-3">
|
||||||
🔐 Secure payments by
|
🔐 Secure payments by
|
||||||
<a href="https://paddle.com" target="_blank" rel="noopener">Paddle<i class="fe fe-external-link"></i></a></li>
|
<a href="https://paddle.com" target="_blank" rel="noopener">
|
||||||
|
Paddle <i class="fe fe-external-link"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -79,9 +80,8 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
Paddle supported payment methods include bank cards (Mastercard, Visa, American Express, etc) or PayPal. <br>
|
Paddle supports bank cards
|
||||||
Send us an email at <a href="mailto:hi@simplelogin.io">hi@simplelogin.io</a> if you need other payment options
|
(Mastercard, Visa, American Express, etc) and PayPal.
|
||||||
(e.g. IBAN transfer).
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-success" onclick="upgrade({{ PADDLE_MONTHLY_PRODUCT_ID }})">
|
<button class="btn btn-success" onclick="upgrade({{ PADDLE_MONTHLY_PRODUCT_ID }})">
|
||||||
|
@ -93,6 +93,24 @@
|
||||||
Yearly <br>
|
Yearly <br>
|
||||||
$30/year
|
$30/year
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{% if current_user.can_use_coinbase %}
|
||||||
|
<hr>
|
||||||
|
Payment via
|
||||||
|
<a href="https://commerce.coinbase.com/?lang=en" target="_blank">
|
||||||
|
Coinbase Commerce<i class="fe fe-external-link"></i>
|
||||||
|
</a> <br>
|
||||||
|
Only the yearly plan is supported. <br>
|
||||||
|
|
||||||
|
<a class="btn btn-primary" href="{{ url_for('dashboard.coinbase_checkout_route') }}"
|
||||||
|
target="_blank">
|
||||||
|
$30/year - Crypto <i class="fe fe-external-link"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
For other payment options, please send us an email at <a href="mailto:hi@simplelogin.io">hi@simplelogin.io</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -39,7 +39,7 @@
|
||||||
{% if current_user.lifetime %}
|
{% if current_user.lifetime %}
|
||||||
You have however lifetime access to the Premium plan now so make sure to cancel the previous plan :).
|
You have however lifetime access to the Premium plan now so make sure to cancel the previous plan :).
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif manual_sub %}
|
{% elif manual_sub and manual_sub.is_active() %}
|
||||||
You are on the Premium plan which expires {{ manual_sub.end_at | dt }}
|
You are on the Premium plan which expires {{ manual_sub.end_at | dt }}
|
||||||
({{ manual_sub.end_at.format("YYYY-MM-DD") }}).
|
({{ manual_sub.end_at.format("YYYY-MM-DD") }}).
|
||||||
{% if manual_sub.is_giveaway %}
|
{% if manual_sub.is_giveaway %}
|
||||||
|
@ -48,6 +48,15 @@
|
||||||
<a href="{{ url_for('dashboard.pricing') }}" class="btn btn-sm btn-outline-primary">Upgrade</a>
|
<a href="{{ url_for('dashboard.pricing') }}" class="btn btn-sm btn-outline-primary">Upgrade</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% elif coinbase_sub and coinbase_sub.is_active() %}
|
||||||
|
You are on the Premium plan which expires {{ coinbase_sub.end_at | dt }}
|
||||||
|
({{ coinbase_sub.end_at.format("YYYY-MM-DD") }}).
|
||||||
|
<br>
|
||||||
|
<a href="{{ url_for('dashboard.coinbase_checkout_route') }}"
|
||||||
|
class="btn btn-sm btn-outline-primary" target="_blank">
|
||||||
|
Extend Subscription <i class="fe fe-external-link"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
{% elif current_user.in_trial() %}
|
{% elif current_user.in_trial() %}
|
||||||
Your Premium trial expires {{ current_user.trial_end | dt }}.
|
Your Premium trial expires {{ current_user.trial_end | dt }}.
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
from coinbase_commerce import Client
|
||||||
from flask import render_template, flash, redirect, url_for
|
from flask import render_template, flash, redirect, url_for
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
|
|
||||||
|
@ -6,8 +7,11 @@ from app.config import (
|
||||||
PADDLE_MONTHLY_PRODUCT_ID,
|
PADDLE_MONTHLY_PRODUCT_ID,
|
||||||
PADDLE_YEARLY_PRODUCT_ID,
|
PADDLE_YEARLY_PRODUCT_ID,
|
||||||
URL,
|
URL,
|
||||||
|
COINBASE_YEARLY_PRICE,
|
||||||
|
COINBASE_API_KEY,
|
||||||
)
|
)
|
||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
|
from app.log import LOG
|
||||||
|
|
||||||
|
|
||||||
@dashboard_bp.route("/pricing", methods=["GET", "POST"])
|
@dashboard_bp.route("/pricing", methods=["GET", "POST"])
|
||||||
|
@ -31,3 +35,19 @@ def pricing():
|
||||||
def subscription_success():
|
def subscription_success():
|
||||||
flash("Thanks so much for supporting SimpleLogin!", "success")
|
flash("Thanks so much for supporting SimpleLogin!", "success")
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
|
|
||||||
|
|
||||||
|
@dashboard_bp.route("/coinbase_checkout")
|
||||||
|
@login_required
|
||||||
|
def coinbase_checkout_route():
|
||||||
|
client = Client(api_key=COINBASE_API_KEY)
|
||||||
|
charge = client.charge.create(
|
||||||
|
name="1 Year SimpleLogin Premium Subscription",
|
||||||
|
local_price={"amount": str(COINBASE_YEARLY_PRICE), "currency": "USD"},
|
||||||
|
pricing_type="fixed_price",
|
||||||
|
metadata={"user_id": current_user.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
LOG.d("Create coinbase charge %s", charge)
|
||||||
|
|
||||||
|
return redirect(charge["hosted_url"])
|
||||||
|
|
|
@ -40,6 +40,7 @@ from app.models import (
|
||||||
ManualSubscription,
|
ManualSubscription,
|
||||||
SenderFormatEnum,
|
SenderFormatEnum,
|
||||||
SLDomain,
|
SLDomain,
|
||||||
|
CoinbaseSubscription,
|
||||||
)
|
)
|
||||||
from app.utils import random_string
|
from app.utils import random_string
|
||||||
|
|
||||||
|
@ -304,6 +305,8 @@ def setting():
|
||||||
return output
|
return output
|
||||||
|
|
||||||
manual_sub = ManualSubscription.get_by(user_id=current_user.id)
|
manual_sub = ManualSubscription.get_by(user_id=current_user.id)
|
||||||
|
coinbase_sub = CoinbaseSubscription.get_by(user_id=current_user.id)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard/setting.html",
|
"dashboard/setting.html",
|
||||||
form=form,
|
form=form,
|
||||||
|
@ -314,6 +317,7 @@ def setting():
|
||||||
pending_email=pending_email,
|
pending_email=pending_email,
|
||||||
AliasGeneratorEnum=AliasGeneratorEnum,
|
AliasGeneratorEnum=AliasGeneratorEnum,
|
||||||
manual_sub=manual_sub,
|
manual_sub=manual_sub,
|
||||||
|
coinbase_sub=coinbase_sub,
|
||||||
FIRST_ALIAS_DOMAIN=FIRST_ALIAS_DOMAIN,
|
FIRST_ALIAS_DOMAIN=FIRST_ALIAS_DOMAIN,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -277,6 +277,11 @@ class User(db.Model, ModelMixin, UserMixin):
|
||||||
db.Boolean, default=False, nullable=True
|
db.Boolean, default=False, nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# AB test the coinbase integration
|
||||||
|
can_use_coinbase = db.Column(
|
||||||
|
db.Boolean, default=False, nullable=False, server_default="0"
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, email, name, password=None, **kwargs):
|
def create(cls, email, name, password=None, **kwargs):
|
||||||
user: User = super(User, cls).create(email=email, name=name, **kwargs)
|
user: User = super(User, cls).create(email=email, name=name, **kwargs)
|
||||||
|
@ -341,7 +346,13 @@ class User(db.Model, ModelMixin, UserMixin):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id)
|
manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id)
|
||||||
if manual_sub and manual_sub.end_at > arrow.now():
|
if manual_sub and manual_sub.is_active():
|
||||||
|
return True
|
||||||
|
|
||||||
|
coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by(
|
||||||
|
user_id=self.id
|
||||||
|
)
|
||||||
|
if coinbase_subscription and coinbase_subscription.is_active():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
@ -357,11 +368,13 @@ class User(db.Model, ModelMixin, UserMixin):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id)
|
manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id)
|
||||||
if (
|
if manual_sub and not manual_sub.is_giveaway and manual_sub.is_active():
|
||||||
manual_sub
|
return True
|
||||||
and not manual_sub.is_giveaway
|
|
||||||
and manual_sub.end_at > arrow.now()
|
coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by(
|
||||||
):
|
user_id=self.id
|
||||||
|
)
|
||||||
|
if coinbase_subscription and coinbase_subscription.is_active():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
@ -387,8 +400,15 @@ class User(db.Model, ModelMixin, UserMixin):
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def can_upgrade(self):
|
def can_upgrade(self) -> bool:
|
||||||
"""User who has lifetime licence or giveaway manual subscriptions can decide to upgrade to a paid plan"""
|
"""
|
||||||
|
The following users can upgrade:
|
||||||
|
- have giveaway lifetime licence
|
||||||
|
- have giveaway manual subscriptions
|
||||||
|
- have a cancelled Paddle subscription
|
||||||
|
- have a expired Apple subscription
|
||||||
|
- have a expired Coinbase subscription
|
||||||
|
"""
|
||||||
sub: Subscription = self.get_subscription()
|
sub: Subscription = self.get_subscription()
|
||||||
# user who has canceled can also re-subscribe
|
# user who has canceled can also re-subscribe
|
||||||
if sub and not sub.cancelled:
|
if sub and not sub.cancelled:
|
||||||
|
@ -400,11 +420,11 @@ class User(db.Model, ModelMixin, UserMixin):
|
||||||
|
|
||||||
manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id)
|
manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id)
|
||||||
# user who has giveaway premium can decide to upgrade
|
# user who has giveaway premium can decide to upgrade
|
||||||
if (
|
if manual_sub and manual_sub.is_active() and not manual_sub.is_giveaway:
|
||||||
manual_sub
|
return False
|
||||||
and manual_sub.end_at > arrow.now()
|
|
||||||
and not manual_sub.is_giveaway
|
coinbase_subscription = CoinbaseSubscription.get_by(user_id=self.id)
|
||||||
):
|
if coinbase_subscription and coinbase_subscription.is_active():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
@ -477,7 +497,8 @@ class User(db.Model, ModelMixin, UserMixin):
|
||||||
return "".join([n[0].upper() for n in names if n])
|
return "".join([n[0].upper() for n in names if n])
|
||||||
|
|
||||||
def get_subscription(self) -> Optional["Subscription"]:
|
def get_subscription(self) -> Optional["Subscription"]:
|
||||||
"""return *active* subscription
|
"""return *active* Paddle subscription
|
||||||
|
Return None if the subscription is already expired
|
||||||
TODO: support user unsubscribe and re-subscribe
|
TODO: support user unsubscribe and re-subscribe
|
||||||
"""
|
"""
|
||||||
sub = Subscription.get_by(user_id=self.id)
|
sub = Subscription.get_by(user_id=self.id)
|
||||||
|
@ -1434,6 +1455,30 @@ class ManualSubscription(db.Model, ModelMixin):
|
||||||
|
|
||||||
user = db.relationship(User)
|
user = db.relationship(User)
|
||||||
|
|
||||||
|
def is_active(self):
|
||||||
|
return self.end_at > arrow.now()
|
||||||
|
|
||||||
|
|
||||||
|
class CoinbaseSubscription(db.Model, ModelMixin):
|
||||||
|
"""
|
||||||
|
For subscriptions using Coinbase Commerce
|
||||||
|
"""
|
||||||
|
|
||||||
|
user_id = db.Column(
|
||||||
|
db.ForeignKey(User.id, ondelete="cascade"), nullable=False, unique=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# an reminder is sent several days before the subscription ends
|
||||||
|
end_at = db.Column(ArrowType, nullable=False)
|
||||||
|
|
||||||
|
# the Coinbase code
|
||||||
|
code = db.Column(db.String(64), nullable=True)
|
||||||
|
|
||||||
|
user = db.relationship(User)
|
||||||
|
|
||||||
|
def is_active(self):
|
||||||
|
return self.end_at > arrow.now()
|
||||||
|
|
||||||
|
|
||||||
# https://help.apple.com/app-store-connect/#/dev58bda3212
|
# https://help.apple.com/app-store-connect/#/dev58bda3212
|
||||||
_APPLE_GRACE_PERIOD_DAYS = 16
|
_APPLE_GRACE_PERIOD_DAYS = 16
|
||||||
|
|
39
cron.py
39
cron.py
|
@ -42,6 +42,7 @@ from app.models import (
|
||||||
Mailbox,
|
Mailbox,
|
||||||
Monitoring,
|
Monitoring,
|
||||||
Contact,
|
Contact,
|
||||||
|
CoinbaseSubscription,
|
||||||
)
|
)
|
||||||
from server import create_app
|
from server import create_app
|
||||||
|
|
||||||
|
@ -114,7 +115,7 @@ def notify_manual_sub_end():
|
||||||
LOG.debug("Remind user %s that their manual sub is ending soon", user)
|
LOG.debug("Remind user %s that their manual sub is ending soon", user)
|
||||||
send_email(
|
send_email(
|
||||||
user.email,
|
user.email,
|
||||||
f"Your trial will end soon {user.name}",
|
f"Your subscription will end soon {user.name}",
|
||||||
render(
|
render(
|
||||||
"transactional/manual-subscription-end.txt",
|
"transactional/manual-subscription-end.txt",
|
||||||
name=user.name,
|
name=user.name,
|
||||||
|
@ -129,6 +130,42 @@ def notify_manual_sub_end():
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
extend_subscription_url = URL + "/dashboard/coinbase_checkout"
|
||||||
|
for coinbase_subscription in CoinbaseSubscription.query.all():
|
||||||
|
need_reminder = False
|
||||||
|
if (
|
||||||
|
arrow.now().shift(days=14)
|
||||||
|
> coinbase_subscription.end_at
|
||||||
|
> arrow.now().shift(days=13)
|
||||||
|
):
|
||||||
|
need_reminder = True
|
||||||
|
elif (
|
||||||
|
arrow.now().shift(days=4)
|
||||||
|
> coinbase_subscription.end_at
|
||||||
|
> arrow.now().shift(days=3)
|
||||||
|
):
|
||||||
|
need_reminder = True
|
||||||
|
|
||||||
|
if need_reminder:
|
||||||
|
user = coinbase_subscription.user
|
||||||
|
LOG.debug(
|
||||||
|
"Remind user %s that their coinbase subscription is ending soon", user
|
||||||
|
)
|
||||||
|
send_email(
|
||||||
|
user.email,
|
||||||
|
"Your SimpleLogin subscription will end soon",
|
||||||
|
render(
|
||||||
|
"transactional/coinbase/reminder-subscription.txt",
|
||||||
|
coinbase_subscription=coinbase_subscription,
|
||||||
|
extend_subscription_url=extend_subscription_url,
|
||||||
|
),
|
||||||
|
render(
|
||||||
|
"transactional/coinbase/reminder-subscription.html",
|
||||||
|
coinbase_subscription=coinbase_subscription,
|
||||||
|
extend_subscription_url=extend_subscription_url,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def poll_apple_subscription():
|
def poll_apple_subscription():
|
||||||
"""Poll Apple API to update AppleSubscription"""
|
"""Poll Apple API to update AppleSubscription"""
|
||||||
|
|
|
@ -166,3 +166,9 @@ DISABLE_ONBOARDING=true
|
||||||
|
|
||||||
# if set, used to sign the forwarding emails
|
# if set, used to sign the forwarding emails
|
||||||
# PGP_SENDER_PRIVATE_KEY_PATH=local_data/private-pgp.asc
|
# PGP_SENDER_PRIVATE_KEY_PATH=local_data/private-pgp.asc
|
||||||
|
|
||||||
|
# Coinbase
|
||||||
|
# COINBASE_WEBHOOK_SECRET=to_fill
|
||||||
|
# COINBASE_CHECKOUT_ID=to_fill
|
||||||
|
# COINBASE_API_KEY=to_fill
|
||||||
|
# COINBASE_YEARLY_PRICE=30.00
|
29
migrations/versions/2020_121319_0af2c2e286a7_.py
Normal file
29
migrations/versions/2020_121319_0af2c2e286a7_.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 0af2c2e286a7
|
||||||
|
Revises: a20aeb9b0eac
|
||||||
|
Create Date: 2020-12-13 19:20:18.250786
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '0af2c2e286a7'
|
||||||
|
down_revision = 'a20aeb9b0eac'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('users', sa.Column('can_use_coinbase', sa.Boolean(), server_default='0', nullable=False))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('users', 'can_use_coinbase')
|
||||||
|
# ### end Alembic commands ###
|
39
migrations/versions/2020_121319_a20aeb9b0eac_.py
Normal file
39
migrations/versions/2020_121319_a20aeb9b0eac_.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: a20aeb9b0eac
|
||||||
|
Revises: 780a8344914b
|
||||||
|
Create Date: 2020-12-13 19:04:46.771429
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'a20aeb9b0eac'
|
||||||
|
down_revision = '780a8344914b'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('coinbase_subscription',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||||
|
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('end_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=64), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('user_id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('coinbase_subscription')
|
||||||
|
# ### end Alembic commands ###
|
1072
poetry.lock
generated
1072
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -75,6 +75,7 @@ aiospamc = "^0.6.1"
|
||||||
email_validator = "^1.1.1"
|
email_validator = "^1.1.1"
|
||||||
PGPy = "^0.5.3"
|
PGPy = "^0.5.3"
|
||||||
py3-validate-email = "^0.2.10"
|
py3-validate-email = "^0.2.10"
|
||||||
|
coinbase-commerce = "^1.0.1"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "^6.1.0"
|
pytest = "^6.1.0"
|
||||||
|
@ -83,10 +84,6 @@ pre-commit = "^2.7.1"
|
||||||
pytest-cov = "^2.10.1"
|
pytest-cov = "^2.10.1"
|
||||||
flake8 = "^3.8.4"
|
flake8 = "^3.8.4"
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["poetry>=0.12"]
|
|
||||||
build-backend = "poetry.masonry.api"
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = """
|
addopts = """
|
||||||
--cov=.
|
--cov=.
|
||||||
|
@ -94,3 +91,7 @@ addopts = """
|
||||||
--cov-report=html:htmlcov
|
--cov-report=html:htmlcov
|
||||||
--cov-fail-under=60
|
--cov-fail-under=60
|
||||||
"""
|
"""
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry>=0.12"]
|
||||||
|
build-backend = "poetry.masonry.api"
|
||||||
|
|
||||||
|
|
115
server.py
115
server.py
|
@ -6,6 +6,8 @@ from datetime import timedelta
|
||||||
import arrow
|
import arrow
|
||||||
import flask_profiler
|
import flask_profiler
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
|
from coinbase_commerce.error import WebhookInvalidPayload, SignatureVerificationError
|
||||||
|
from coinbase_commerce.webhook import Webhook
|
||||||
from flask import (
|
from flask import (
|
||||||
Flask,
|
Flask,
|
||||||
redirect,
|
redirect,
|
||||||
|
@ -53,6 +55,7 @@ from app.config import (
|
||||||
PADDLE_MONTHLY_PRODUCT_IDS,
|
PADDLE_MONTHLY_PRODUCT_IDS,
|
||||||
PADDLE_YEARLY_PRODUCT_IDS,
|
PADDLE_YEARLY_PRODUCT_IDS,
|
||||||
PGP_SIGNER,
|
PGP_SIGNER,
|
||||||
|
COINBASE_WEBHOOK_SECRET,
|
||||||
)
|
)
|
||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
from app.developer.base import developer_bp
|
from app.developer.base import developer_bp
|
||||||
|
@ -77,6 +80,7 @@ from app.models import (
|
||||||
Referral,
|
Referral,
|
||||||
AliasMailbox,
|
AliasMailbox,
|
||||||
Notification,
|
Notification,
|
||||||
|
CoinbaseSubscription,
|
||||||
)
|
)
|
||||||
from app.monitor.base import monitor_bp
|
from app.monitor.base import monitor_bp
|
||||||
from app.oauth.base import oauth_bp
|
from app.oauth.base import oauth_bp
|
||||||
|
@ -142,6 +146,7 @@ def create_app() -> Flask:
|
||||||
|
|
||||||
init_admin(app)
|
init_admin(app)
|
||||||
setup_paddle_callback(app)
|
setup_paddle_callback(app)
|
||||||
|
setup_coinbase_commerce(app)
|
||||||
setup_do_not_track(app)
|
setup_do_not_track(app)
|
||||||
|
|
||||||
if FLASK_PROFILER_PATH:
|
if FLASK_PROFILER_PATH:
|
||||||
|
@ -198,20 +203,23 @@ def fake_data():
|
||||||
|
|
||||||
user.trial_end = None
|
user.trial_end = None
|
||||||
|
|
||||||
LifetimeCoupon.create(code="coupon", nb_used=10)
|
LifetimeCoupon.create(code="coupon", nb_used=10, commit=True)
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Create a subscription for user
|
# Create a subscription for user
|
||||||
Subscription.create(
|
# Subscription.create(
|
||||||
user_id=user.id,
|
# user_id=user.id,
|
||||||
cancel_url="https://checkout.paddle.com/subscription/cancel?user=1234",
|
# cancel_url="https://checkout.paddle.com/subscription/cancel?user=1234",
|
||||||
update_url="https://checkout.paddle.com/subscription/update?user=1234",
|
# update_url="https://checkout.paddle.com/subscription/update?user=1234",
|
||||||
subscription_id="123",
|
# subscription_id="123",
|
||||||
event_time=arrow.now(),
|
# event_time=arrow.now(),
|
||||||
next_bill_date=arrow.now().shift(days=10).date(),
|
# next_bill_date=arrow.now().shift(days=10).date(),
|
||||||
plan=PlanEnum.monthly,
|
# plan=PlanEnum.monthly,
|
||||||
|
# )
|
||||||
|
# db.session.commit()
|
||||||
|
|
||||||
|
CoinbaseSubscription.create(
|
||||||
|
user_id=user.id, end_at=arrow.now().shift(days=10), commit=True
|
||||||
)
|
)
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
api_key = ApiKey.create(user_id=user.id, name="Chrome")
|
api_key = ApiKey.create(user_id=user.id, name="Chrome")
|
||||||
api_key.code = "code"
|
api_key.code = "code"
|
||||||
|
@ -634,6 +642,91 @@ def setup_paddle_callback(app: Flask):
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_coinbase_commerce(app):
|
||||||
|
@app.route("/coinbase", methods=["POST"])
|
||||||
|
def coinbase_webhook():
|
||||||
|
# event payload
|
||||||
|
request_data = request.data.decode("utf-8")
|
||||||
|
# webhook signature
|
||||||
|
request_sig = request.headers.get("X-CC-Webhook-Signature", None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# signature verification and event object construction
|
||||||
|
event = Webhook.construct_event(
|
||||||
|
request_data, request_sig, COINBASE_WEBHOOK_SECRET
|
||||||
|
)
|
||||||
|
except (WebhookInvalidPayload, SignatureVerificationError) as e:
|
||||||
|
LOG.exception("Invalid Coinbase webhook")
|
||||||
|
return str(e), 400
|
||||||
|
|
||||||
|
LOG.d("Coinbase event %s", event)
|
||||||
|
|
||||||
|
if event["type"] == "charge:confirmed":
|
||||||
|
if handle_coinbase_event(event):
|
||||||
|
return "success", 200
|
||||||
|
else:
|
||||||
|
return "error", 400
|
||||||
|
|
||||||
|
return "success", 200
|
||||||
|
|
||||||
|
|
||||||
|
def handle_coinbase_event(event) -> bool:
|
||||||
|
user_id = int(event["data"]["metadata"]["user_id"])
|
||||||
|
code = event["data"]["code"]
|
||||||
|
user = User.get(user_id)
|
||||||
|
if not user:
|
||||||
|
LOG.exception("User not found %s", user_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by(
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not coinbase_subscription:
|
||||||
|
LOG.d("Create a coinbase subscription for %s", user)
|
||||||
|
coinbase_subscription = CoinbaseSubscription.create(
|
||||||
|
user_id=user_id, end_at=arrow.now().shift(years=1), code=code, commit=True
|
||||||
|
)
|
||||||
|
send_email(
|
||||||
|
user.email,
|
||||||
|
"Your SimpleLogin account has been upgraded",
|
||||||
|
render(
|
||||||
|
"transactional/coinbase/new-subscription.txt",
|
||||||
|
coinbase_subscription=coinbase_subscription,
|
||||||
|
),
|
||||||
|
render(
|
||||||
|
"transactional/coinbase/new-subscription.html",
|
||||||
|
coinbase_subscription=coinbase_subscription,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if coinbase_subscription.code != code:
|
||||||
|
LOG.d("Update code from %s to %s", coinbase_subscription.code, code)
|
||||||
|
coinbase_subscription.code = code
|
||||||
|
|
||||||
|
if coinbase_subscription.is_active():
|
||||||
|
coinbase_subscription.end_at = coinbase_subscription.end_at.shift(years=1)
|
||||||
|
else: # already expired subscription
|
||||||
|
coinbase_subscription.end_at = arrow.now().shift(years=1)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
send_email(
|
||||||
|
user.email,
|
||||||
|
"Your SimpleLogin account has been extended",
|
||||||
|
render(
|
||||||
|
"transactional/coinbase/extend-subscription.txt",
|
||||||
|
coinbase_subscription=coinbase_subscription,
|
||||||
|
),
|
||||||
|
render(
|
||||||
|
"transactional/coinbase/extend-subscription.html",
|
||||||
|
coinbase_subscription=coinbase_subscription,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def init_extensions(app: Flask):
|
def init_extensions(app: Flask):
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% call text() %}
|
||||||
|
<h1>
|
||||||
|
Your subscription has been extended!
|
||||||
|
</h1>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call text() %}
|
||||||
|
Your payment with cryptocurrency has been successfully processed. <br>
|
||||||
|
Your subscription has been extended to
|
||||||
|
<b>{{ coinbase_subscription.end_at.format("YYYY-MM-DD") }}</b>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
|
||||||
|
{% call text() %}
|
||||||
|
Thank you a lot for your support!
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
|
||||||
|
{{ render_text('Best, <br />SimpleLogin Team.') }}
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,11 @@
|
||||||
|
Your subscription has been extended!
|
||||||
|
|
||||||
|
Your payment with cryptocurrency has been successfully processed.
|
||||||
|
|
||||||
|
Your subscription has been extended to
|
||||||
|
{{ coinbase_subscription.end_at.format("YYYY-MM-DD") }}
|
||||||
|
|
||||||
|
Thank you a lot for your support!
|
||||||
|
|
||||||
|
Best,
|
||||||
|
SimpleLogin team.
|
|
@ -0,0 +1,22 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% call text() %}
|
||||||
|
<h1>
|
||||||
|
Your account has been upgraded!
|
||||||
|
</h1>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call text() %}
|
||||||
|
Your payment with cryptocurrency has been successfully processed. <br>
|
||||||
|
Your account has been upgraded to the premium plan until
|
||||||
|
<b>{{ coinbase_subscription.end_at.format("YYYY-MM-DD") }}</b>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call text() %}
|
||||||
|
Thank you a lot for your support!
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
|
||||||
|
{{ render_text('Best, <br />SimpleLogin Team.') }}
|
||||||
|
{% endblock %}
|
11
templates/emails/transactional/coinbase/new-subscription.txt
Normal file
11
templates/emails/transactional/coinbase/new-subscription.txt
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
Your account has been upgraded!
|
||||||
|
|
||||||
|
Your payment with cryptocurrency has been successfully processed.
|
||||||
|
|
||||||
|
Your account has been upgraded to premium plan until
|
||||||
|
{{ coinbase_subscription.end_at.format("YYYY-MM-DD") }}
|
||||||
|
|
||||||
|
Thank you a lot for your support!
|
||||||
|
|
||||||
|
Best,
|
||||||
|
SimpleLogin team.
|
|
@ -0,0 +1,18 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% call text() %}
|
||||||
|
<h1>
|
||||||
|
Your subscription is ending soon.
|
||||||
|
</h1>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call text() %}
|
||||||
|
Your subscription ends on
|
||||||
|
<b>{{ coinbase_subscription.end_at.format("YYYY-MM-DD") }}</b>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{{ render_button("Extend your subscription", extend_subscription_url) }}
|
||||||
|
|
||||||
|
{{ render_text('Best, <br />SimpleLogin Team.') }}
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,7 @@
|
||||||
|
Your subscription ends on {{ coinbase_subscription.end_at.format("YYYY-MM-DD") }}
|
||||||
|
|
||||||
|
You can extend your subscription on
|
||||||
|
{{ extend_subscription_url }}
|
||||||
|
|
||||||
|
Best,
|
||||||
|
SimpleLogin team.
|
19
tests/test_cron.py
Normal file
19
tests/test_cron.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import arrow
|
||||||
|
|
||||||
|
from app.models import User, CoinbaseSubscription
|
||||||
|
from cron import notify_manual_sub_end
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_manual_sub_end(flask_client):
|
||||||
|
user = User.create(
|
||||||
|
email="a@b.c",
|
||||||
|
password="password",
|
||||||
|
name="Test User",
|
||||||
|
activated=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
CoinbaseSubscription.create(
|
||||||
|
user_id=user.id, end_at=arrow.now().shift(days=13, hours=2), commit=True
|
||||||
|
)
|
||||||
|
|
||||||
|
notify_manual_sub_end()
|
|
@ -1,6 +1,64 @@
|
||||||
|
import arrow
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import User, CoinbaseSubscription
|
||||||
|
from server import handle_coinbase_event
|
||||||
|
|
||||||
|
|
||||||
def test_redirect_login_page(flask_client):
|
def test_redirect_login_page(flask_client):
|
||||||
"""Start with a blank database."""
|
"""Start with a blank database."""
|
||||||
|
|
||||||
rv = flask_client.get("/")
|
rv = flask_client.get("/")
|
||||||
assert rv.status_code == 302
|
assert rv.status_code == 302
|
||||||
assert rv.location == "http://sl.test/auth/login"
|
assert rv.location == "http://sl.test/auth/login"
|
||||||
|
|
||||||
|
|
||||||
|
def test_coinbase_webhook(flask_client):
|
||||||
|
r = flask_client.post("/coinbase")
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_coinbase_event_new_subscription(flask_client):
|
||||||
|
user = User.create(
|
||||||
|
email="a@b.c",
|
||||||
|
password="password",
|
||||||
|
name="Test User",
|
||||||
|
activated=True,
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
handle_coinbase_event(
|
||||||
|
{"data": {"code": "AAAAAA", "metadata": {"user_id": str(user.id)}}}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user.is_paid()
|
||||||
|
assert user.is_premium()
|
||||||
|
|
||||||
|
assert CoinbaseSubscription.get_by(user_id=user.id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_coinbase_event_extend_subscription(flask_client):
|
||||||
|
user = User.create(
|
||||||
|
email="a@b.c",
|
||||||
|
password="password",
|
||||||
|
name="Test User",
|
||||||
|
activated=True,
|
||||||
|
)
|
||||||
|
user.trial_end = None
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
cb = CoinbaseSubscription.create(
|
||||||
|
user_id=user.id, end_at=arrow.now().shift(days=-400), commit=True
|
||||||
|
)
|
||||||
|
assert not cb.is_active()
|
||||||
|
|
||||||
|
assert not user.is_paid()
|
||||||
|
assert not user.is_premium()
|
||||||
|
|
||||||
|
handle_coinbase_event(
|
||||||
|
{"data": {"code": "AAAAAA", "metadata": {"user_id": str(user.id)}}}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user.is_paid()
|
||||||
|
assert user.is_premium()
|
||||||
|
|
||||||
|
assert CoinbaseSubscription.get_by(user_id=user.id) is not None
|
||||||
|
|
Loading…
Reference in a new issue