Send email to users with a subscription and a partner plan upgrade (#1101)

* Send email to users with a subscription and a partner plan upgrade

* Update double-subscription-partner.html

* Update double-subscription-partner.txt.jinja2

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
Co-authored-by: Son Nguyen Kim <nguyenkims@users.noreply.github.com>
This commit is contained in:
Adrià Casajús 2022-06-20 14:34:20 +02:00 committed by GitHub
parent fbb59a1531
commit 99ce10a1bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 231 additions and 80 deletions

View File

@ -2,13 +2,25 @@ from abc import ABC, abstractmethod
from arrow import Arrow from arrow import Arrow
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from flask import url_for
from newrelic import agent from newrelic import agent
from typing import Optional from typing import Optional
from app import config
from app.db import Session from app.db import Session
from app.email_utils import send_email_at_most_times, render
from app.errors import AccountAlreadyLinkedToAnotherPartnerException from app.errors import AccountAlreadyLinkedToAnotherPartnerException
from app.log import LOG from app.log import LOG
from app.models import PartnerSubscription, Partner, PartnerUser, User from app.models import (
PartnerSubscription,
Partner,
PartnerUser,
User,
AppleSubscription,
Subscription,
)
from app.proton.utils import is_proton_partner
from app.utils import random_string from app.utils import random_string
@ -38,6 +50,36 @@ class LinkResult:
strategy: str strategy: str
def send_double_subscription_if_needed(partner_user: PartnerUser):
sub = partner_user.user.get_active_subscription()
if isinstance(sub, AppleSubscription):
channel = "Apple"
elif isinstance(sub, Subscription):
channel = "Paddle"
else:
return
send_email_at_most_times(
partner_user.user,
config.ALERT_DUAL_SUBSCRIPTION_WITH_PARTNER,
partner_user.user.email,
f"You have two subscriptions in SimpleLogin",
render(
"transactional/double-subscription-partner.txt.jinja2",
is_proton=is_proton_partner(partner_user.partner),
partner=partner_user.partner,
subscription_channel=channel,
cancel_link=url_for("dashboard.billing"),
),
render(
"transactional/double-subscription-partner.html",
is_proton=is_proton_partner(partner_user.partner),
partner=partner_user.partner,
subscription_channel=channel,
cancel_link=url_for("dashboard.billing"),
),
)
def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan): def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
sub = PartnerSubscription.get_by(partner_user_id=partner_user.id) sub = PartnerSubscription.get_by(partner_user_id=partner_user.id)
if plan.type == SLPlanType.Free: if plan.type == SLPlanType.Free:
@ -66,6 +108,7 @@ def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
"PlanChange", {"plan": "premium", "type": "extension"} "PlanChange", {"plan": "premium", "type": "extension"}
) )
sub.end_at = plan.expiration sub.end_at = plan.expiration
send_double_subscription_if_needed(partner_user)
Session.commit() Session.commit()

View File

@ -219,7 +219,7 @@ def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
flash(f"user {user} already has a lifetime license", "warning") flash(f"user {user} already has a lifetime license", "warning")
continue continue
sub: Subscription = user.get_subscription() sub: Subscription = user.get_paddle_subscription()
if sub and not sub.cancelled: if sub and not sub.cancelled:
flash( flash(
f"user {user} already has a Paddle license, they have to cancel it first", f"user {user} already has a Paddle license, they have to cancel it first",

View File

@ -19,8 +19,8 @@ from app.proton.proton_client import HttpProtonClient, convert_access_token
from app.proton.proton_callback_handler import ( from app.proton.proton_callback_handler import (
ProtonCallbackHandler, ProtonCallbackHandler,
Action, Action,
get_proton_partner,
) )
from app.proton.utils import get_proton_partner
from app.utils import sanitize_next_url from app.utils import sanitize_next_url
_authorization_base_url = PROTON_BASE_URL + "/oauth/authorize" _authorization_base_url = PROTON_BASE_URL + "/oauth/authorize"

View File

@ -351,6 +351,8 @@ ALERT_COMPLAINT_TRANSACTIONAL_PHASE = "alert_complaint_transactional_phase"
ALERT_QUARANTINE_DMARC = "alert_quarantine_dmarc" ALERT_QUARANTINE_DMARC = "alert_quarantine_dmarc"
ALERT_DUAL_SUBSCRIPTION_WITH_PARTNER = "alert_dual_sub_with_partner"
# <<<<< END ALERT EMAIL >>>> # <<<<< END ALERT EMAIL >>>>
# Disable onboarding emails # Disable onboarding emails

View File

@ -13,7 +13,7 @@ from app.paddle_utils import cancel_subscription, change_plan
@login_required @login_required
def billing(): def billing():
# sanity check: make sure this page is only for user who has paddle subscription # sanity check: make sure this page is only for user who has paddle subscription
sub: Subscription = current_user.get_subscription() sub: Subscription = current_user.get_paddle_subscription()
if not sub: if not sub:
flash("You don't have any active subscription", "warning") flash("You don't have any active subscription", "warning")

View File

@ -40,7 +40,7 @@ def coupon_route():
if current_user.lifetime: if current_user.lifetime:
can_use_coupon = False can_use_coupon = False
sub: Subscription = current_user.get_subscription() sub: Subscription = current_user.get_paddle_subscription()
if sub: if sub:
can_use_coupon = False can_use_coupon = False

View File

@ -14,7 +14,7 @@ from app.models import Subscription, Job
@sudo_required @sudo_required
def delete_account(): def delete_account():
if request.method == "POST" and request.form.get("form-name") == "delete-account": if request.method == "POST" and request.form.get("form-name") == "delete-account":
sub: Subscription = current_user.get_subscription() sub: Subscription = current_user.get_paddle_subscription()
# user who has canceled can also re-subscribe # user who has canceled can also re-subscribe
if sub and not sub.cancelled: if sub and not sub.cancelled:
flash("Please cancel your current subscription first", "warning") flash("Please cancel your current subscription first", "warning")

View File

@ -23,7 +23,7 @@ def lifetime_licence():
# user needs to cancel active subscription first # user needs to cancel active subscription first
# to avoid being charged # to avoid being charged
sub = current_user.get_subscription() sub = current_user.get_paddle_subscription()
if sub and not sub.cancelled: if sub and not sub.cancelled:
flash("Please cancel your current subscription first", "warning") flash("Please cancel your current subscription first", "warning")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))

View File

@ -29,7 +29,7 @@ def pricing():
flash("You already have a lifetime subscription", "error") flash("You already have a lifetime subscription", "error")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
sub: Subscription = current_user.get_subscription() sub: Subscription = current_user.get_paddle_subscription()
# user who has canceled can re-subscribe # user who has canceled can re-subscribe
if sub and not sub.cancelled: if sub and not sub.cancelled:
flash("You already have an active subscription", "error") flash("You already have an active subscription", "error")

View File

@ -49,8 +49,7 @@ from app.models import (
AppleSubscription, AppleSubscription,
PartnerUser, PartnerUser,
) )
from app.proton.utils import is_connect_with_proton_enabled from app.proton.utils import is_connect_with_proton_enabled, get_proton_partner
from app.proton.proton_callback_handler import get_proton_partner
from app.utils import random_string, sanitize_email from app.utils import random_string, sanitize_email

View File

@ -8,7 +8,7 @@ import os
import random import random
import uuid import uuid
from email.utils import formataddr from email.utils import formataddr
from typing import List, Tuple, Optional from typing import List, Tuple, Optional, Union
import arrow import arrow
import sqlalchemy as sa import sqlalchemy as sa
@ -597,57 +597,59 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return user return user
def get_active_subscription(
self,
) -> Optional[
Union[
Subscription
| AppleSubscription
| ManualSubscription
| CoinbaseSubscription
| PartnerSubscription
]
]:
sub: Subscription = self.get_paddle_subscription()
if sub:
return sub
apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=self.id)
if apple_sub and apple_sub.is_valid():
return apple_sub
manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id)
if manual_sub and manual_sub.is_active():
return manual_sub
coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by(
user_id=self.id
)
if coinbase_subscription and coinbase_subscription.is_active():
return coinbase_subscription
partner_sub: PartnerSubscription = PartnerSubscription.find_by_user_id(self.id)
if partner_sub and partner_sub.is_active():
return partner_sub
return None
# region Billing # region Billing
def lifetime_or_active_subscription(self) -> bool: def lifetime_or_active_subscription(self) -> bool:
"""True if user has lifetime licence or active subscription""" """True if user has lifetime licence or active subscription"""
if self.lifetime: if self.lifetime:
return True return True
sub: Subscription = self.get_subscription() return self.get_active_subscription() is not None
if sub:
return True
apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=self.id)
if apple_sub and apple_sub.is_valid():
return True
manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id)
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
partner_sub: PartnerSubscription = PartnerSubscription.find_by_user_id(self.id)
if partner_sub and partner_sub.is_active():
return True
return False
def is_paid(self) -> bool: def is_paid(self) -> bool:
"""same as _lifetime_or_active_subscription but not include free manual subscription""" """same as _lifetime_or_active_subscription but not include free manual subscription"""
sub: Subscription = self.get_subscription() sub = self.get_active_subscription()
if sub: if sub is None:
return True return False
apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=self.id) if isinstance(sub, ManualSubscription) and sub.is_giveaway():
if apple_sub and apple_sub.is_valid(): return False
return True
manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id) return True
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
def in_trial(self): def in_trial(self):
"""return True if user does not have lifetime licence or an active subscription AND is in trial period""" """return True if user does not have lifetime licence or an active subscription AND is in trial period"""
@ -665,7 +667,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
if self.lifetime_or_active_subscription(): if self.lifetime_or_active_subscription():
# user who has canceled can also re-subscribe # user who has canceled can also re-subscribe
sub: Subscription = self.get_subscription() sub: Subscription = self.get_paddle_subscription()
if sub and sub.cancelled: if sub and sub.cancelled:
return True return True
@ -696,7 +698,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
if self.lifetime: if self.lifetime:
channels.append("Lifetime") channels.append("Lifetime")
sub: Subscription = self.get_subscription() sub: Subscription = self.get_paddle_subscription()
if sub: if sub:
if sub.cancelled: if sub.cancelled:
channels.append( channels.append(
@ -779,7 +781,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
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 get_subscription(self) -> Optional["Subscription"]: def get_paddle_subscription(self) -> Optional["Subscription"]:
"""return *active* Paddle subscription """return *active* Paddle subscription
Return None if the subscription is already expired Return None if the subscription is already expired
TODO: support user unsubscribe and re-subscribe TODO: support user unsubscribe and re-subscribe
@ -3165,6 +3167,7 @@ class PartnerUser(Base, ModelMixin):
partner_email = sa.Column(sa.String(255), unique=False, nullable=True) partner_email = sa.Column(sa.String(255), unique=False, nullable=True)
user = orm.relationship(User, foreign_keys=[user_id]) user = orm.relationship(User, foreign_keys=[user_id])
partner = orm.relationship(Partner, foreign_keys=[partner_id])
__table_args__ = ( __table_args__ = (
sa.UniqueConstraint( sa.UniqueConstraint(

View File

@ -3,8 +3,7 @@ from enum import Enum
from flask import url_for from flask import url_for
from typing import Optional from typing import Optional
from app.db import Session from app.errors import LinkException
from app.errors import LinkException, ProtonPartnerNotSetUp
from app.models import User, Partner from app.models import User, Partner
from app.proton.proton_client import ProtonClient, ProtonUser from app.proton.proton_client import ProtonClient, ProtonUser
from app.account_linking import ( from app.account_linking import (
@ -13,20 +12,6 @@ from app.account_linking import (
PartnerLinkRequest, PartnerLinkRequest,
) )
PROTON_PARTNER_NAME = "Proton"
_PROTON_PARTNER: Optional[Partner] = None
def get_proton_partner() -> Partner:
global _PROTON_PARTNER
if _PROTON_PARTNER is None:
partner = Partner.get_by(name=PROTON_PARTNER_NAME)
if partner is None:
raise ProtonPartnerNotSetUp
Session.expunge(partner)
_PROTON_PARTNER = partner
return _PROTON_PARTNER
class Action(Enum): class Action(Enum):
Login = 1 Login = 1

View File

@ -1,6 +1,30 @@
from typing import Optional
from app.config import CONNECT_WITH_PROTON, CONNECT_WITH_PROTON_COOKIE_NAME from app.config import CONNECT_WITH_PROTON, CONNECT_WITH_PROTON_COOKIE_NAME
from flask import request from flask import request
from app.db import Session
from app.errors import ProtonPartnerNotSetUp
from app.models import Partner
PROTON_PARTNER_NAME = "Proton"
_PROTON_PARTNER: Optional[Partner] = None
def get_proton_partner() -> Partner:
global _PROTON_PARTNER
if _PROTON_PARTNER is None:
partner = Partner.get_by(name=PROTON_PARTNER_NAME)
if partner is None:
raise ProtonPartnerNotSetUp
Session.expunge(partner)
_PROTON_PARTNER = partner
return _PROTON_PARTNER
def is_proton_partner(partner: Partner) -> str:
return partner.name == PROTON_PARTNER_NAME
def is_connect_with_proton_enabled() -> bool: def is_connect_with_proton_enabled() -> bool:
if CONNECT_WITH_PROTON: if CONNECT_WITH_PROTON:

View File

@ -170,7 +170,7 @@ def notify_manual_sub_end():
LOG.d("%s has a lifetime licence", user) LOG.d("%s has a lifetime licence", user)
continue continue
paddle_sub: Subscription = user.get_subscription() paddle_sub: Subscription = user.get_paddle_subscription()
if paddle_sub and not paddle_sub.cancelled: if paddle_sub and not paddle_sub.cancelled:
LOG.d("%s has an active Paddle subscription", user) LOG.d("%s has an active Paddle subscription", user)
continue continue
@ -179,7 +179,7 @@ def notify_manual_sub_end():
# user can have a (free) manual subscription but has taken a paid subscription via # user can have a (free) manual subscription but has taken a paid subscription via
# Paddle, Coinbase or Apple since then # Paddle, Coinbase or Apple since then
if manual_sub.is_giveaway: if manual_sub.is_giveaway:
if user.get_subscription(): if user.get_paddle_subscription():
LOG.d("%s has a active Paddle subscription", user) LOG.d("%s has a active Paddle subscription", user)
continue continue

View File

@ -6,7 +6,7 @@ from app.db import Session
from app.log import LOG from app.log import LOG
from app.models import Mailbox, Contact, SLDomain, Partner from app.models import Mailbox, Contact, SLDomain, Partner
from app.pgp_utils import load_public_key from app.pgp_utils import load_public_key
from app.proton.proton_callback_handler import PROTON_PARTNER_NAME from app.proton.utils import PROTON_PARTNER_NAME
from server import create_light_app from server import create_light_app

View File

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block content %}
{% call text() %}
Hi,
{% endcall %}
{% call text() %}
{% if is_proton %}
As a Proton user with a paid mail subscription you can have SimpleLogin premium for free, thanks to the SimpleLogin Proton integration.<br>
{% else %}
{{ partner.name }} has upgraded your account to premium.<br>
{% endif %}
You currently have a paid SimpleLogin subscription via {{ subscription_channel }}.<br>
{% if subscription_channel == 'apple' %}
You can cancel it to avoid automatic renewal on your Apple iCloud account page.
You can find more info <a href="https://support.apple.com/en-us/HT202039">here</a>.<br>
{% elif subscription_channel == 'paddle' %}
You can cancel it to avoid automatic renewal on <a href="{{ cancel_link }}">{{ cancel_link }}</a>.<br>
{% endif %}
You can also keep both subscriptions. In this case theres nothing to do.
{% endcall %}
{% call text() %}
Best, <br>
SimpleLogin team.
{% endcall %}
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "base.txt.jinja2" %}
{% block content %}
Hi,
{% if is_proton -%}
As a Proton user with a paid mail subscription you can have SimpleLogin premium for free, thanks to the SimpleLogin Proton integration.
{% else -%}}
{{ partner.name }} has upgraded your account to premium.
{% endif -%}
You currently have a paid SimpleLogin subscription via {subscription_channel}.
{% if subscription_channel == 'Apple' %}
You can cancel it to avoid automatic renewal on your Apple iCloud account page.
Please find more info at https://support.apple.com/en-us/HT202039
{% elif subscription_channel == 'Paddle' %}
You can cancel it to avoid automatic renewal on {{ cancel_link }}.
{% endif %}
You can also keep both subscriptions. In this case theres nothing to do.
Best,
SimpleLogin team.
{% endblock %}

View File

@ -1,3 +1,5 @@
import random
from app.config import ( from app.config import (
MAX_ACTIVITY_DURING_MINUTE_PER_ALIAS, MAX_ACTIVITY_DURING_MINUTE_PER_ALIAS,
MAX_ACTIVITY_DURING_MINUTE_PER_MAILBOX, MAX_ACTIVITY_DURING_MINUTE_PER_MAILBOX,
@ -88,11 +90,12 @@ def test_rate_limited_reply_phase(flask_client):
alias = Alias.create_new_random(user) alias = Alias.create_new_random(user)
Session.commit() Session.commit()
reply_email = f"reply-{random.random()}@sl.local"
contact = Contact.create( contact = Contact.create(
user_id=user.id, user_id=user.id,
alias_id=alias.id, alias_id=alias.id,
website_email="contact@example.com", website_email="contact@example.com",
reply_email="rep@sl.local", reply_email=reply_email,
) )
Session.commit() Session.commit()
for _ in range(MAX_ACTIVITY_DURING_MINUTE_PER_ALIAS + 1): for _ in range(MAX_ACTIVITY_DURING_MINUTE_PER_ALIAS + 1):
@ -103,4 +106,4 @@ def test_rate_limited_reply_phase(flask_client):
) )
Session.commit() Session.commit()
assert rate_limited_reply_phase("rep@sl.local") assert rate_limited_reply_phase(reply_email)

View File

@ -6,10 +6,10 @@ from app.account_linking import (
from app.proton.proton_client import ProtonClient, UserInformation from app.proton.proton_client import ProtonClient, UserInformation
from app.proton.proton_callback_handler import ( from app.proton.proton_callback_handler import (
ProtonCallbackHandler, ProtonCallbackHandler,
get_proton_partner,
generate_account_not_allowed_to_log_in, generate_account_not_allowed_to_log_in,
) )
from app.models import User, PartnerUser from app.models import User, PartnerUser
from app.proton.utils import get_proton_partner
from app.utils import random_string from app.utils import random_string
from typing import Optional from typing import Optional
from tests.utils import random_email from tests.utils import random_email

View File

@ -1,6 +1,10 @@
import random
import arrow
import pytest import pytest
from arrow import Arrow from arrow import Arrow
from app import config
from app.account_linking import ( from app.account_linking import (
process_link_case, process_link_case,
process_login_case, process_login_case,
@ -13,11 +17,13 @@ from app.account_linking import (
SLPlanType, SLPlanType,
PartnerLinkRequest, PartnerLinkRequest,
ClientMergeStrategy, ClientMergeStrategy,
set_plan_for_partner_user,
) )
from app.proton.proton_callback_handler import get_proton_partner from app.mail_sender import mail_sender
from app.db import Session from app.db import Session
from app.errors import AccountAlreadyLinkedToAnotherPartnerException from app.errors import AccountAlreadyLinkedToAnotherPartnerException
from app.models import Partner, PartnerUser, User from app.models import Partner, PartnerUser, User, Subscription, PlanEnum, SentAlert
from app.proton.utils import get_proton_partner
from app.utils import random_string from app.utils import random_string
from tests.utils import random_email from tests.utils import random_email
@ -320,3 +326,39 @@ def test_ensure_partner_user_exists_for_user_raises_exception_when_linked_to_ano
user, user,
partner_2, partner_2,
) )
@mail_sender.store_emails_test_decorator
def test_send_double_sub_email_is_sent(flask_client):
user = create_user(random_email())
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=int(random.randint(10000, 999999999)),
event_time=arrow.now(),
next_bill_date=arrow.now().shift(days=10).date(),
plan=PlanEnum.monthly,
commit=True,
)
partner = Partner.create(
name=random_string(),
contact_email=random_email(),
flush=True,
)
partner_user = PartnerUser.create(
user_id=user.id,
partner_id=partner.id,
partner_email=user.email,
external_user_id=random_string(),
commit=True,
)
set_plan_for_partner_user(
partner_user,
SLPlan(type=SLPlanType.Premium, expiration=arrow.now().shift(months=10)),
)
emails_sent = mail_sender.get_stored_emails()
assert len(emails_sent) == 1
alerts = SentAlert.filter_by(user_id=user.id).all()
assert len(alerts) == 1
assert alerts[0].alert_type == config.ALERT_DUAL_SUBSCRIPTION_WITH_PARTNER

View File

@ -278,12 +278,12 @@ def test_user_get_subscription_grace_period(flask_client):
commit=True, commit=True,
) )
assert user.get_subscription() is not None assert user.get_paddle_subscription() is not None
sub.next_bill_date = ( sub.next_bill_date = (
arrow.now().shift(days=-(PADDLE_SUBSCRIPTION_GRACE_DAYS + 1)).date() arrow.now().shift(days=-(PADDLE_SUBSCRIPTION_GRACE_DAYS + 1)).date()
) )
assert user.get_subscription() is None assert user.get_paddle_subscription() is None
def test_create_contact_for_noreply(flask_client): def test_create_contact_for_noreply(flask_client):