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 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> </form>
<hr> <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> <h3>Change password</h3>
<form method="post"> <form method="post">
<input type="hidden" name="form-name" value="change-password"> <input type="hidden" name="form-name" value="change-password">

View File

@ -55,83 +55,10 @@ def setting():
db.session.commit() db.session.commit()
flash(f"Your profile has been updated", "success") 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": elif request.form.get("form-name") == "change-password":
send_reset_password_email(current_user) 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")) return redirect(url_for("dashboard.setting"))

View File

@ -3,8 +3,6 @@ import random
import arrow import arrow
import bcrypt import bcrypt
import stripe
from arrow import Arrow
from flask import url_for from flask import url_for
from flask_login import UserMixin from flask_login import UserMixin
from sqlalchemy import text from sqlalchemy import text
@ -80,8 +78,7 @@ class File(db.Model, ModelMixin):
class PlanEnum(enum.Enum): class PlanEnum(enum.Enum):
free = 0 monthly = 2
trial = 1
yearly = 3 yearly = 3
@ -95,15 +92,7 @@ class User(db.Model, ModelMixin, UserMixin):
activated = db.Column(db.Boolean, default=False, nullable=False) activated = db.Column(db.Boolean, default=False, nullable=False)
plan = db.Column( trial_expiration = db.Column(ArrowType)
db.Enum(PlanEnum),
nullable=False,
default=PlanEnum.free,
server_default=PlanEnum.free.name,
)
# only relevant for trial period
plan_expiration = db.Column(ArrowType)
stripe_customer_id = db.Column(db.String(128), unique=True) stripe_customer_id = db.Column(db.String(128), unique=True)
stripe_card_token = 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) 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) profile_picture = db.relationship(File)
@classmethod @classmethod
@ -127,8 +113,7 @@ class User(db.Model, ModelMixin, UserMixin):
user.set_password(password) user.set_password(password)
# by default new user will be trial period # by default new user will be trial period
user.plan = PlanEnum.trial user.trial_expiration = arrow.now().shift(days=+15)
user.plan_expiration = arrow.now().shift(days=+15)
db.session.flush() db.session.flush()
# create a first alias mail to show user how to use when they login # 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 return user
def should_upgrade(self): def should_upgrade(self):
"""User is invited to upgrade if they are in free plan or their trial ends soon""" return not self.is_premium()
if self.plan == PlanEnum.free:
return True
elif self.plan == PlanEnum.trial:
return True
return False
def is_premium(self): 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): def can_create_custom_email(self):
if self.is_premium(): if self.is_premium():
return True return True
# plan not expired yet # trial not expired yet
elif self.plan == PlanEnum.trial and self.plan_expiration > arrow.now(): elif self.trial_expiration > arrow.now():
return True return True
return False return False
def can_create_new_email(self): def can_create_new_email(self):
if self.is_premium(): if self.is_premium():
return True return True
# plan not expired yet # trial not expired yet
elif self.plan == PlanEnum.trial and self.plan_expiration > arrow.now(): elif self.trial_expiration > arrow.now():
return True return True
else: # free or trial expired else: # free or trial expired
return GenEmail.filter_by(user_id=self.id).count() < MAX_NB_EMAIL_FREE_PLAN 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: else:
return url_for("static", filename="default-avatar.png") 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]): def suggested_emails(self) -> (str, [str]):
"""return suggested email and other email choices """ """return suggested email and other email choices """
all_gen_emails = [ge.email for ge in GenEmail.filter_by(user_id=self.id)] 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(" ") names = self.name.split(" ")
return "".join([n[0].upper() for n in names if n]) 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): def __repr__(self):
return f"<User {self.id} {self.name} {self.email}>" return f"<User {self.id} {self.name} {self.email}>"

30
cron.py
View File

@ -1,32 +1,16 @@
import arrow import arrow
import stripe
from app.extensions import db
from app.log import LOG from app.log import LOG
from app.models import User, PlanEnum from app.models import Subscription
from server import create_app from server import create_app
def downgrade_expired_plan(): def late_payment():
"""set user plan to free when plan is expired, ie plan_expiration < now """check for late payment
""" """
for user in User.query.filter( for sub in Subscription.query.all():
User.plan != PlanEnum.free, User.plan_expiration < arrow.now() if (not sub.cancelled) and sub.next_bill_date < arrow.now():
).all(): LOG.error(f"user {sub.user.email} has late payment. {sub}")
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()
if __name__ == "__main__": if __name__ == "__main__":
@ -34,4 +18,4 @@ if __name__ == "__main__":
app = create_app() app = create_app()
with app.app_context(): 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( user = User.create(
email="a@b.c", password="password", name="Test User", activated=True email="a@b.c", password="password", name="Test User", activated=True
) )
user.plan = PlanEnum.free
db.session.commit() db.session.commit()
# make sure user runs out of quota to create new email # make sure user runs out of quota to create new email