mirror of
https://github.com/simple-login/app.git
synced 2024-09-28 12:41:29 +02:00
Change subscription model
- create subscription table - rename plan_expiration -> trial_expiration - remove user.plan, user.promo_codes
This commit is contained in:
parent
11ba3b82bc
commit
263f68ecec
33
README.md
33
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
|
||||
|
||||
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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"))
|
||||
|
||||
|
@ -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
30
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()
|
||||
|
53
migrations/versions/4a640c170d02_.py
Normal file
53
migrations/versions/4a640c170d02_.py
Normal 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 ###
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user