From 263f68ecec7b99ff835667b9372819de66220133 Mon Sep 17 00:00:00 2001 From: Son NK Date: Thu, 14 Nov 2019 14:52:33 +0100 Subject: [PATCH] Change subscription model - create subscription table - rename plan_expiration -> trial_expiration - remove user.plan, user.promo_codes --- README.md | 33 -------- .../templates/dashboard/setting.html | 56 +------------ app/dashboard/views/setting.py | 77 +---------------- app/models.py | 82 +++++++------------ cron.py | 30 ++----- migrations/versions/4a640c170d02_.py | 53 ++++++++++++ tests/test_models.py | 1 - 7 files changed, 92 insertions(+), 240 deletions(-) create mode 100644 migrations/versions/4a640c170d02_.py diff --git a/README.md b/README.md index 9453469c..6e5a6659 100644 --- a/README.md +++ b/README.md @@ -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 - - diff --git a/app/dashboard/templates/dashboard/setting.html b/app/dashboard/templates/dashboard/setting.html index 751a5748..2a9b654d 100644 --- a/app/dashboard/templates/dashboard/setting.html +++ b/app/dashboard/templates/dashboard/setting.html @@ -33,61 +33,7 @@
-

Current subscription

- Your current plan is - {% if current_user.is_premium() %} - {{ current_user.plan.name }} -
- {% if current_user.plan_expiration %} - Ends {{ current_user.plan_expiration.humanize() }} - {% else %} - Renewed {{ current_user.plan_current_period_end().humanize() }} - {% endif %} - {% else %} - {{ current_user.plan.name }}
- {% if current_user.plan == PlanEnum.trial %} - Ends {{ current_user.plan_expiration.humanize() }}
- {% endif %} - - Upgrade To Premium - -

-
- {{ promo_form.csrf_token }} - -
If you have a promo code, you can enter it here
-

You can use a given promo code only once :)

-
- - {{ promo_form.code(class="form-control") }} - {{ render_field_errors(promo_form.code) }} -
- -
- {% endif %} - - {% if current_user.is_premium() %} - - {% if current_user.plan_expiration %} -
- -

- -
- {% else %} - -
- -

- -
- {% endif %} - {% endif %} - -
+

Change password

diff --git a/app/dashboard/views/setting.py b/app/dashboard/views/setting.py index c0c1eebc..48b10591 100644 --- a/app/dashboard/views/setting.py +++ b/app/dashboard/views/setting.py @@ -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")) diff --git a/app/models.py b/app/models.py index 0b620db6..62e1dca3 100644 --- a/app/models.py +++ b/app/models.py @@ -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"" diff --git a/cron.py b/cron.py index 5b4ed97e..4fd51994 100644 --- a/cron.py +++ b/cron.py @@ -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() diff --git a/migrations/versions/4a640c170d02_.py b/migrations/versions/4a640c170d02_.py new file mode 100644 index 00000000..7ad7515e --- /dev/null +++ b/migrations/versions/4a640c170d02_.py @@ -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 ### diff --git a/tests/test_models.py b/tests/test_models.py index e2221757..f8075e04 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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