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:
parent
fbb59a1531
commit
99ce10a1bc
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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"))
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
4
cron.py
4
cron.py
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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 there’s nothing to do.
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call text() %}
|
||||||
|
Best, <br>
|
||||||
|
SimpleLogin team.
|
||||||
|
{% endcall %}
|
||||||
|
{% endblock %}
|
|
@ -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 there’s nothing to do.
|
||||||
|
|
||||||
|
Best,
|
||||||
|
SimpleLogin team.
|
||||||
|
{% endblock %}
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
Loading…
Reference in New Issue