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
|
||||
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>
|
||||
</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>
|
||||
</a></div>
|
||||
</div>
|
||||
|
@ -64,7 +64,8 @@
|
|||
<div class="col-sm-6 col-lg-6">
|
||||
<div class="display-6 my-3">
|
||||
🔐 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>
|
||||
</div>
|
||||
|
||||
|
@ -79,9 +80,8 @@
|
|||
{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
Paddle supported payment methods include bank cards (Mastercard, Visa, American Express, etc) or PayPal. <br>
|
||||
Send us an email at <a href="mailto:hi@simplelogin.io">hi@simplelogin.io</a> if you need other payment options
|
||||
(e.g. IBAN transfer).
|
||||
Paddle supports bank cards
|
||||
(Mastercard, Visa, American Express, etc) and PayPal.
|
||||
</div>
|
||||
|
||||
<button class="btn btn-success" onclick="upgrade({{ PADDLE_MONTHLY_PRODUCT_ID }})">
|
||||
|
@ -93,6 +93,24 @@
|
|||
Yearly <br>
|
||||
$30/year
|
||||
</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>
|
||||
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
{% if current_user.lifetime %}
|
||||
You have however lifetime access to the Premium plan now so make sure to cancel the previous plan :).
|
||||
{% 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 }}
|
||||
({{ manual_sub.end_at.format("YYYY-MM-DD") }}).
|
||||
{% if manual_sub.is_giveaway %}
|
||||
|
@ -48,6 +48,15 @@
|
|||
<a href="{{ url_for('dashboard.pricing') }}" class="btn btn-sm btn-outline-primary">Upgrade</a>
|
||||
{% 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() %}
|
||||
Your Premium trial expires {{ current_user.trial_end | dt }}.
|
||||
{% else %}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from coinbase_commerce import Client
|
||||
from flask import render_template, flash, redirect, url_for
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
|
@ -6,8 +7,11 @@ from app.config import (
|
|||
PADDLE_MONTHLY_PRODUCT_ID,
|
||||
PADDLE_YEARLY_PRODUCT_ID,
|
||||
URL,
|
||||
COINBASE_YEARLY_PRICE,
|
||||
COINBASE_API_KEY,
|
||||
)
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.log import LOG
|
||||
|
||||
|
||||
@dashboard_bp.route("/pricing", methods=["GET", "POST"])
|
||||
|
@ -31,3 +35,19 @@ def pricing():
|
|||
def subscription_success():
|
||||
flash("Thanks so much for supporting SimpleLogin!", "success")
|
||||
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,
|
||||
SenderFormatEnum,
|
||||
SLDomain,
|
||||
CoinbaseSubscription,
|
||||
)
|
||||
from app.utils import random_string
|
||||
|
||||
|
@ -304,6 +305,8 @@ def setting():
|
|||
return output
|
||||
|
||||
manual_sub = ManualSubscription.get_by(user_id=current_user.id)
|
||||
coinbase_sub = CoinbaseSubscription.get_by(user_id=current_user.id)
|
||||
|
||||
return render_template(
|
||||
"dashboard/setting.html",
|
||||
form=form,
|
||||
|
@ -314,6 +317,7 @@ def setting():
|
|||
pending_email=pending_email,
|
||||
AliasGeneratorEnum=AliasGeneratorEnum,
|
||||
manual_sub=manual_sub,
|
||||
coinbase_sub=coinbase_sub,
|
||||
FIRST_ALIAS_DOMAIN=FIRST_ALIAS_DOMAIN,
|
||||
)
|
||||
|
||||
|
|
|
@ -277,6 +277,11 @@ class User(db.Model, ModelMixin, UserMixin):
|
|||
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
|
||||
def create(cls, email, name, password=None, **kwargs):
|
||||
user: User = super(User, cls).create(email=email, name=name, **kwargs)
|
||||
|
@ -341,7 +346,13 @@ class User(db.Model, ModelMixin, UserMixin):
|
|||
return True
|
||||
|
||||
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 False
|
||||
|
@ -357,11 +368,13 @@ class User(db.Model, ModelMixin, UserMixin):
|
|||
return True
|
||||
|
||||
manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id)
|
||||
if (
|
||||
manual_sub
|
||||
and not manual_sub.is_giveaway
|
||||
and manual_sub.end_at > arrow.now()
|
||||
):
|
||||
if manual_sub and not manual_sub.is_giveaway 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 False
|
||||
|
@ -387,8 +400,15 @@ class User(db.Model, ModelMixin, UserMixin):
|
|||
|
||||
return True
|
||||
|
||||
def can_upgrade(self):
|
||||
"""User who has lifetime licence or giveaway manual subscriptions can decide to upgrade to a paid plan"""
|
||||
def can_upgrade(self) -> bool:
|
||||
"""
|
||||
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()
|
||||
# user who has canceled can also re-subscribe
|
||||
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)
|
||||
# user who has giveaway premium can decide to upgrade
|
||||
if (
|
||||
manual_sub
|
||||
and manual_sub.end_at > arrow.now()
|
||||
and not manual_sub.is_giveaway
|
||||
):
|
||||
if manual_sub and manual_sub.is_active() and not manual_sub.is_giveaway:
|
||||
return False
|
||||
|
||||
coinbase_subscription = CoinbaseSubscription.get_by(user_id=self.id)
|
||||
if coinbase_subscription and coinbase_subscription.is_active():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
@ -477,7 +497,8 @@ class User(db.Model, ModelMixin, UserMixin):
|
|||
return "".join([n[0].upper() for n in names if n])
|
||||
|
||||
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
|
||||
"""
|
||||
sub = Subscription.get_by(user_id=self.id)
|
||||
|
@ -1434,6 +1455,30 @@ class ManualSubscription(db.Model, ModelMixin):
|
|||
|
||||
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
|
||||
_APPLE_GRACE_PERIOD_DAYS = 16
|
||||
|
|
39
cron.py
39
cron.py
|
@ -42,6 +42,7 @@ from app.models import (
|
|||
Mailbox,
|
||||
Monitoring,
|
||||
Contact,
|
||||
CoinbaseSubscription,
|
||||
)
|
||||
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)
|
||||
send_email(
|
||||
user.email,
|
||||
f"Your trial will end soon {user.name}",
|
||||
f"Your subscription will end soon {user.name}",
|
||||
render(
|
||||
"transactional/manual-subscription-end.txt",
|
||||
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():
|
||||
"""Poll Apple API to update AppleSubscription"""
|
||||
|
|
|
@ -166,3 +166,9 @@ DISABLE_ONBOARDING=true
|
|||
|
||||
# if set, used to sign the forwarding emails
|
||||
# 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"
|
||||
PGPy = "^0.5.3"
|
||||
py3-validate-email = "^0.2.10"
|
||||
coinbase-commerce = "^1.0.1"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^6.1.0"
|
||||
|
@ -83,10 +84,6 @@ pre-commit = "^2.7.1"
|
|||
pytest-cov = "^2.10.1"
|
||||
flake8 = "^3.8.4"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry>=0.12"]
|
||||
build-backend = "poetry.masonry.api"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = """
|
||||
--cov=.
|
||||
|
@ -94,3 +91,7 @@ addopts = """
|
|||
--cov-report=html:htmlcov
|
||||
--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 flask_profiler
|
||||
import sentry_sdk
|
||||
from coinbase_commerce.error import WebhookInvalidPayload, SignatureVerificationError
|
||||
from coinbase_commerce.webhook import Webhook
|
||||
from flask import (
|
||||
Flask,
|
||||
redirect,
|
||||
|
@ -53,6 +55,7 @@ from app.config import (
|
|||
PADDLE_MONTHLY_PRODUCT_IDS,
|
||||
PADDLE_YEARLY_PRODUCT_IDS,
|
||||
PGP_SIGNER,
|
||||
COINBASE_WEBHOOK_SECRET,
|
||||
)
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.developer.base import developer_bp
|
||||
|
@ -77,6 +80,7 @@ from app.models import (
|
|||
Referral,
|
||||
AliasMailbox,
|
||||
Notification,
|
||||
CoinbaseSubscription,
|
||||
)
|
||||
from app.monitor.base import monitor_bp
|
||||
from app.oauth.base import oauth_bp
|
||||
|
@ -142,6 +146,7 @@ def create_app() -> Flask:
|
|||
|
||||
init_admin(app)
|
||||
setup_paddle_callback(app)
|
||||
setup_coinbase_commerce(app)
|
||||
setup_do_not_track(app)
|
||||
|
||||
if FLASK_PROFILER_PATH:
|
||||
|
@ -198,20 +203,23 @@ def fake_data():
|
|||
|
||||
user.trial_end = None
|
||||
|
||||
LifetimeCoupon.create(code="coupon", nb_used=10)
|
||||
db.session.commit()
|
||||
LifetimeCoupon.create(code="coupon", nb_used=10, commit=True)
|
||||
|
||||
# Create a subscription for user
|
||||
Subscription.create(
|
||||
user_id=user.id,
|
||||
cancel_url="https://checkout.paddle.com/subscription/cancel?user=1234",
|
||||
update_url="https://checkout.paddle.com/subscription/update?user=1234",
|
||||
subscription_id="123",
|
||||
event_time=arrow.now(),
|
||||
next_bill_date=arrow.now().shift(days=10).date(),
|
||||
plan=PlanEnum.monthly,
|
||||
# Subscription.create(
|
||||
# user_id=user.id,
|
||||
# cancel_url="https://checkout.paddle.com/subscription/cancel?user=1234",
|
||||
# update_url="https://checkout.paddle.com/subscription/update?user=1234",
|
||||
# subscription_id="123",
|
||||
# event_time=arrow.now(),
|
||||
# next_bill_date=arrow.now().shift(days=10).date(),
|
||||
# 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.code = "code"
|
||||
|
@ -634,6 +642,91 @@ def setup_paddle_callback(app: Flask):
|
|||
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):
|
||||
login_manager.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):
|
||||
"""Start with a blank database."""
|
||||
|
||||
rv = flask_client.get("/")
|
||||
assert rv.status_code == 302
|
||||
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