Change subscription model

- create subscription table
- rename plan_expiration -> trial_expiration
- remove user.plan, user.promo_codes
This commit is contained in:
Son NK 2019-11-14 14:52:33 +01:00
parent 11ba3b82bc
commit 263f68ecec
7 changed files with 92 additions and 240 deletions

View File

@ -74,37 +74,4 @@ response_type=id_token code
return `id_token` in addition to `authorization_code` in /authorization endpoint
# Plan Upgrade, downgrade flow
Here's an example:
July 2019: user takes yearly plan, valid until July 2020
user.plan=yearly, user.plan_expiration=None
set user.stripe card-token, customer-id, subscription-id
December 2019: user cancels his plan.
set plan_expiration to "period end of subscription", ie July 2020
call stripe:
stripe.Subscription.modify(
user.stripe_subscription_id,
cancel_at_period_end=True
)
There are 2 possible scenarios at this point:
1) user decides to renew on March 2020:
set plan_expiration = None
stripe.Subscription.modify(
user.stripe_subscription_id,
cancel_at_period_end=False
)
2) the plan ends on July 2020.
The cronjob set
- user stripe_subscription_id , stripe_card_token, stripe_customer_id to None
- user.plan=free, user.plan_expiration=None
- delete customer on stripe
user decides to take the premium plan again: go through all normal flow

View File

@ -33,61 +33,7 @@
</form>
<hr>
<h3>Current subscription</h3>
Your current plan is
{% if current_user.is_premium() %}
<b>{{ current_user.plan.name }}</b>
<br>
{% if current_user.plan_expiration %}
Ends {{ current_user.plan_expiration.humanize() }}
{% else %}
Renewed {{ current_user.plan_current_period_end().humanize() }}
{% endif %}
{% else %}
<b>{{ current_user.plan.name }}</b><br>
{% if current_user.plan == PlanEnum.trial %}
Ends {{ current_user.plan_expiration.humanize() }}<br>
{% endif %}
<a href="{{ url_for('dashboard.pricing') }}" class="btn btn-sm btn-outline-primary">
Upgrade To Premium
</a>
<br><br>
<form method="post">
{{ promo_form.csrf_token }}
<input type="hidden" name="form-name" value="promo-code">
<h5>If you have a promo code, you can enter it here</h5>
<p class="text-muted">You can use a given promo code only once :)</p>
<div class="form-group">
<label class="form-label">Promo code</label>
{{ promo_form.code(class="form-control") }}
{{ render_field_errors(promo_form.code) }}
</div>
<button class="btn btn-primary">Apply</button>
</form>
{% endif %}
{% if current_user.is_premium() %}
<!-- This corresponds to the more rare case where user has upgraded the plan,
downgraded it and decides to upgrade again before the end of the previous plan -->
{% if current_user.plan_expiration %}
<form method="post">
<input type="hidden" name="form-name" value="reactivate-subscription">
<br><br>
<button class="btn btn-warning">Reactivate subscription</button>
</form>
{% else %}
<!-- current_user.plan_expiration=None, this corresponds to the usual case
where user has upgraded the plan, and now decide to downgrade it. -->
<form method="post"
onsubmit="return confirm('Your plan will be downgraded to free plan {{ current_user.plan_current_period_end().humanize() }}, please confirm.')">
<input type="hidden" name="form-name" value="cancel-subscription">
<br><br>
<button class="btn btn-warning">Cancel subscription</button>
</form>
{% endif %}
{% endif %}
<hr>
<h3>Change password</h3>
<form method="post">
<input type="hidden" name="form-name" value="change-password">

View File

@ -55,83 +55,10 @@ def setting():
db.session.commit()
flash(f"Your profile has been updated", "success")
elif request.form.get("form-name") == "cancel-subscription":
# sanity check
if not (current_user.is_premium() and current_user.plan_expiration is None):
raise Exception("user cannot cancel subscription")
notify_admin(f"user {current_user} cancels subscription")
# the plan will finish at the end of the current period
current_user.plan_expiration = current_user.plan_current_period_end()
stripe.Subscription.modify(
current_user.stripe_subscription_id, cancel_at_period_end=True
)
db.session.commit()
flash(
f"Your plan will be downgraded {current_user.plan_expiration.humanize()}",
"success",
)
elif request.form.get("form-name") == "reactivate-subscription":
if not (current_user.is_premium() and current_user.plan_expiration):
raise Exception("user cannot reactivate subscription")
notify_admin(f"user {current_user} reactivates subscription")
# the plan will finish at the end of the current period
current_user.plan_expiration = None
stripe.Subscription.modify(
current_user.stripe_subscription_id, cancel_at_period_end=False
)
db.session.commit()
flash(f"Your plan is reactivated now, thank you!", "success")
elif request.form.get("form-name") == "change-password":
send_reset_password_email(current_user)
elif request.form.get("form-name") == "promo-code":
if promo_form.validate():
promo_code = promo_form.code.data.upper()
if promo_code != PROMO_CODE:
flash(
"Unknown promo code. Are you sure this is the right code?",
"warning",
)
return render_template(
"dashboard/setting.html",
form=form,
PlanEnum=PlanEnum,
promo_form=promo_form,
)
elif promo_code in current_user.get_promo_codes():
flash(
"You have already used this promo code. A code can be used only once :(",
"warning",
)
return render_template(
"dashboard/setting.html",
form=form,
PlanEnum=PlanEnum,
promo_form=promo_form,
)
else:
LOG.d("apply promo code %s for user %s", promo_code, current_user)
current_user.plan = PlanEnum.trial
if current_user.plan_expiration:
LOG.d("extend the current plan 1 year")
current_user.plan_expiration = current_user.plan_expiration.shift(
years=1
)
else:
LOG.d("set plan_expiration to 1 year from now")
current_user.plan_expiration = arrow.now().shift(years=1)
current_user.save_new_promo_code(promo_code)
db.session.commit()
flash(
"The promo code has been applied successfully to your account!",
"success",
)
return redirect(url_for("dashboard.setting"))

View File

@ -3,8 +3,6 @@ import random
import arrow
import bcrypt
import stripe
from arrow import Arrow
from flask import url_for
from flask_login import UserMixin
from sqlalchemy import text
@ -80,8 +78,7 @@ class File(db.Model, ModelMixin):
class PlanEnum(enum.Enum):
free = 0
trial = 1
monthly = 2
yearly = 3
@ -95,15 +92,7 @@ class User(db.Model, ModelMixin, UserMixin):
activated = db.Column(db.Boolean, default=False, nullable=False)
plan = db.Column(
db.Enum(PlanEnum),
nullable=False,
default=PlanEnum.free,
server_default=PlanEnum.free.name,
)
# only relevant for trial period
plan_expiration = db.Column(ArrowType)
trial_expiration = db.Column(ArrowType)
stripe_customer_id = db.Column(db.String(128), unique=True)
stripe_card_token = db.Column(db.String(128), unique=True)
@ -111,9 +100,6 @@ class User(db.Model, ModelMixin, UserMixin):
profile_picture_id = db.Column(db.ForeignKey(File.id), nullable=True)
# contain the list of promo codes user has used. Promo codes are separated by ","
promo_codes = db.Column(db.Text, nullable=True)
profile_picture = db.relationship(File)
@classmethod
@ -127,8 +113,7 @@ class User(db.Model, ModelMixin, UserMixin):
user.set_password(password)
# by default new user will be trial period
user.plan = PlanEnum.trial
user.plan_expiration = arrow.now().shift(days=+15)
user.trial_expiration = arrow.now().shift(days=+15)
db.session.flush()
# create a first alias mail to show user how to use when they login
@ -138,29 +123,26 @@ class User(db.Model, ModelMixin, UserMixin):
return user
def should_upgrade(self):
"""User is invited to upgrade if they are in free plan or their trial ends soon"""
if self.plan == PlanEnum.free:
return True
elif self.plan == PlanEnum.trial:
return True
return False
return not self.is_premium()
def is_premium(self):
return self.plan == PlanEnum.yearly
"""user is premium if they have a active subscription"""
sub: Subscription = self.get_subscription()
return sub is not None and sub.next_bill_date > arrow.now().date()
def can_create_custom_email(self):
if self.is_premium():
return True
# plan not expired yet
elif self.plan == PlanEnum.trial and self.plan_expiration > arrow.now():
# trial not expired yet
elif self.trial_expiration > arrow.now():
return True
return False
def can_create_new_email(self):
if self.is_premium():
return True
# plan not expired yet
elif self.plan == PlanEnum.trial and self.plan_expiration > arrow.now():
# trial not expired yet
elif self.trial_expiration > arrow.now():
return True
else: # free or trial expired
return GenEmail.filter_by(user_id=self.id).count() < MAX_NB_EMAIL_FREE_PLAN
@ -181,30 +163,6 @@ class User(db.Model, ModelMixin, UserMixin):
else:
return url_for("static", filename="default-avatar.png")
def plan_current_period_end(self) -> Arrow:
if not self.stripe_subscription_id:
LOG.error(
"plan_current_period_end should not be called with empty stripe_subscription_id"
)
return None
current_period_end_ts = stripe.Subscription.retrieve(
self.stripe_subscription_id
)["current_period_end"]
return arrow.get(current_period_end_ts)
def get_promo_codes(self) -> [str]:
if not self.promo_codes:
return []
return self.promo_codes.split(",")
def save_new_promo_code(self, promo_code):
current_promo_codes = self.get_promo_codes()
current_promo_codes.append(promo_code)
self.promo_codes = ",".join(current_promo_codes)
def suggested_emails(self) -> (str, [str]):
"""return suggested email and other email choices """
all_gen_emails = [ge.email for ge in GenEmail.filter_by(user_id=self.id)]
@ -231,6 +189,24 @@ class User(db.Model, ModelMixin, UserMixin):
names = self.name.split(" ")
return "".join([n[0].upper() for n in names if n])
def plan_name(self) -> str:
if self.is_premium():
sub = self.get_subscription()
if sub.plan == PlanEnum.monthly:
return "Monthly ($2.99/month)"
else:
return "Yearly ($29.99/year)"
elif self.trial_expiration > arrow.now():
return "Trial"
else:
return "Free Plan"
def get_subscription(self):
sub = Subscription.get_by(user_id=self.id)
return sub
def __repr__(self):
return f"<User {self.id} {self.name} {self.email}>"

30
cron.py
View File

@ -1,32 +1,16 @@
import arrow
import stripe
from app.extensions import db
from app.log import LOG
from app.models import User, PlanEnum
from app.models import Subscription
from server import create_app
def downgrade_expired_plan():
"""set user plan to free when plan is expired, ie plan_expiration < now
def late_payment():
"""check for late payment
"""
for user in User.query.filter(
User.plan != PlanEnum.free, User.plan_expiration < arrow.now()
).all():
LOG.d("set user %s to free plan", user)
user.plan_expiration = None
user.plan = PlanEnum.free
if user.stripe_customer_id:
LOG.d("delete user %s on stripe", user.stripe_customer_id)
stripe.Customer.delete(user.stripe_customer_id)
user.stripe_card_token = None
user.stripe_customer_id = None
user.stripe_subscription_id = None
db.session.commit()
for sub in Subscription.query.all():
if (not sub.cancelled) and sub.next_bill_date < arrow.now():
LOG.error(f"user {sub.user.email} has late payment. {sub}")
if __name__ == "__main__":
@ -34,4 +18,4 @@ if __name__ == "__main__":
app = create_app()
with app.app_context():
downgrade_expired_plan()
late_payment()

View File

@ -0,0 +1,53 @@
"""empty message
Revision ID: 4a640c170d02
Revises: 5fa68bafae72
Create Date: 2019-11-14 14:47:36.440551
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '4a640c170d02'
down_revision = '5fa68bafae72'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('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('cancel_url', sa.String(length=1024), nullable=False),
sa.Column('update_url', sa.String(length=1024), nullable=False),
sa.Column('subscription_id', sa.String(length=1024), nullable=False),
sa.Column('event_time', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('next_bill_date', sa.Date(), nullable=False),
sa.Column('cancelled', sa.Boolean(), nullable=False),
sa.Column('plan', sa.Enum('monthly', 'yearly', name='planenum'), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('subscription_id'),
sa.UniqueConstraint('user_id')
)
op.add_column('users', sa.Column('trial_expiration', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True))
op.drop_column('users', 'plan_expiration')
op.drop_column('users', 'plan')
op.drop_column('users', 'promo_codes')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('promo_codes', sa.TEXT(), autoincrement=False, nullable=True))
op.add_column('users', sa.Column('plan', postgresql.ENUM('free', 'trial', 'monthly', 'yearly', name='plan_enum'), server_default=sa.text("'free'::plan_enum"), autoincrement=False, nullable=False))
op.add_column('users', sa.Column('plan_expiration', postgresql.TIMESTAMP(), autoincrement=False, nullable=True))
op.drop_column('users', 'trial_expiration')
op.drop_table('subscription')
# ### end Alembic commands ###

View File

@ -38,7 +38,6 @@ def test_suggested_emails_for_user_who_cannot_create_new_email(flask_client):
user = User.create(
email="a@b.c", password="password", name="Test User", activated=True
)
user.plan = PlanEnum.free
db.session.commit()
# make sure user runs out of quota to create new email