Merge pull request #347 from simple-login/coinbase

Coinbase integration
This commit is contained in:
Son Nguyen Kim 2020-12-14 11:52:50 +01:00 committed by GitHub
commit d161ca94f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1108 additions and 552 deletions

View File

@ -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

View 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 %}

View File

@ -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>

View File

@ -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 %}

View File

@ -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"])

View File

@ -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,
)

View File

@ -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
View File

@ -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"""

View File

@ -165,4 +165,10 @@ DISABLE_ONBOARDING=true
# SPAMASSASSIN_HOST = 127.0.0.1
# 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

View 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 ###

View 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

File diff suppressed because it is too large Load Diff

View File

@ -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
View File

@ -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)

View File

@ -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 %}

View File

@ -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.

View File

@ -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 %}

View 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.

View File

@ -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 %}

View File

@ -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
View 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()

View File

@ -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