diff --git a/app/account_linking.py b/app/account_linking.py
index 096d2d35..f886453d 100644
--- a/app/account_linking.py
+++ b/app/account_linking.py
@@ -2,13 +2,25 @@ from abc import ABC, abstractmethod
from arrow import Arrow
from dataclasses import dataclass
from enum import Enum
+
+from flask import url_for
from newrelic import agent
from typing import Optional
+from app import config
from app.db import Session
+from app.email_utils import send_email_at_most_times, render
from app.errors import AccountAlreadyLinkedToAnotherPartnerException
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
@@ -38,6 +50,36 @@ class LinkResult:
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):
sub = PartnerSubscription.get_by(partner_user_id=partner_user.id)
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"}
)
sub.end_at = plan.expiration
+ send_double_subscription_if_needed(partner_user)
Session.commit()
diff --git a/app/admin_model.py b/app/admin_model.py
index 3351641e..a3c4122e 100644
--- a/app/admin_model.py
+++ b/app/admin_model.py
@@ -219,7 +219,7 @@ def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
flash(f"user {user} already has a lifetime license", "warning")
continue
- sub: Subscription = user.get_subscription()
+ sub: Subscription = user.get_paddle_subscription()
if sub and not sub.cancelled:
flash(
f"user {user} already has a Paddle license, they have to cancel it first",
diff --git a/app/auth/views/proton.py b/app/auth/views/proton.py
index 53ab5dbd..6d82384f 100644
--- a/app/auth/views/proton.py
+++ b/app/auth/views/proton.py
@@ -19,8 +19,8 @@ from app.proton.proton_client import HttpProtonClient, convert_access_token
from app.proton.proton_callback_handler import (
ProtonCallbackHandler,
Action,
- get_proton_partner,
)
+from app.proton.utils import get_proton_partner
from app.utils import sanitize_next_url
_authorization_base_url = PROTON_BASE_URL + "/oauth/authorize"
diff --git a/app/config.py b/app/config.py
index 558f7c24..d27cf58c 100644
--- a/app/config.py
+++ b/app/config.py
@@ -351,6 +351,8 @@ ALERT_COMPLAINT_TRANSACTIONAL_PHASE = "alert_complaint_transactional_phase"
ALERT_QUARANTINE_DMARC = "alert_quarantine_dmarc"
+ALERT_DUAL_SUBSCRIPTION_WITH_PARTNER = "alert_dual_sub_with_partner"
+
# <<<<< END ALERT EMAIL >>>>
# Disable onboarding emails
diff --git a/app/dashboard/views/billing.py b/app/dashboard/views/billing.py
index a0fa87ee..c3ca4337 100644
--- a/app/dashboard/views/billing.py
+++ b/app/dashboard/views/billing.py
@@ -13,7 +13,7 @@ from app.paddle_utils import cancel_subscription, change_plan
@login_required
def billing():
# 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:
flash("You don't have any active subscription", "warning")
diff --git a/app/dashboard/views/coupon.py b/app/dashboard/views/coupon.py
index 3afe1e19..f022a4d7 100644
--- a/app/dashboard/views/coupon.py
+++ b/app/dashboard/views/coupon.py
@@ -40,7 +40,7 @@ def coupon_route():
if current_user.lifetime:
can_use_coupon = False
- sub: Subscription = current_user.get_subscription()
+ sub: Subscription = current_user.get_paddle_subscription()
if sub:
can_use_coupon = False
diff --git a/app/dashboard/views/delete_account.py b/app/dashboard/views/delete_account.py
index 560bbaa7..42c964ef 100644
--- a/app/dashboard/views/delete_account.py
+++ b/app/dashboard/views/delete_account.py
@@ -14,7 +14,7 @@ from app.models import Subscription, Job
@sudo_required
def 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
if sub and not sub.cancelled:
flash("Please cancel your current subscription first", "warning")
diff --git a/app/dashboard/views/lifetime_licence.py b/app/dashboard/views/lifetime_licence.py
index c348bead..2fc4c56b 100644
--- a/app/dashboard/views/lifetime_licence.py
+++ b/app/dashboard/views/lifetime_licence.py
@@ -23,7 +23,7 @@ def lifetime_licence():
# user needs to cancel active subscription first
# to avoid being charged
- sub = current_user.get_subscription()
+ sub = current_user.get_paddle_subscription()
if sub and not sub.cancelled:
flash("Please cancel your current subscription first", "warning")
return redirect(url_for("dashboard.index"))
diff --git a/app/dashboard/views/pricing.py b/app/dashboard/views/pricing.py
index a316ec3e..7bd6334d 100644
--- a/app/dashboard/views/pricing.py
+++ b/app/dashboard/views/pricing.py
@@ -29,7 +29,7 @@ def pricing():
flash("You already have a lifetime subscription", "error")
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
if sub and not sub.cancelled:
flash("You already have an active subscription", "error")
diff --git a/app/dashboard/views/setting.py b/app/dashboard/views/setting.py
index 782be0ca..e20b079c 100644
--- a/app/dashboard/views/setting.py
+++ b/app/dashboard/views/setting.py
@@ -49,8 +49,7 @@ from app.models import (
AppleSubscription,
PartnerUser,
)
-from app.proton.utils import is_connect_with_proton_enabled
-from app.proton.proton_callback_handler import get_proton_partner
+from app.proton.utils import is_connect_with_proton_enabled, get_proton_partner
from app.utils import random_string, sanitize_email
diff --git a/app/models.py b/app/models.py
index e5237263..6ccc734f 100644
--- a/app/models.py
+++ b/app/models.py
@@ -8,7 +8,7 @@ import os
import random
import uuid
from email.utils import formataddr
-from typing import List, Tuple, Optional
+from typing import List, Tuple, Optional, Union
import arrow
import sqlalchemy as sa
@@ -597,57 +597,59 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
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
def lifetime_or_active_subscription(self) -> bool:
"""True if user has lifetime licence or active subscription"""
if self.lifetime:
return True
- sub: Subscription = self.get_subscription()
- 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
+ return self.get_active_subscription() is not None
def is_paid(self) -> bool:
"""same as _lifetime_or_active_subscription but not include free manual subscription"""
- sub: Subscription = self.get_subscription()
- if sub:
- return True
+ sub = self.get_active_subscription()
+ if sub is None:
+ return False
- apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=self.id)
- if apple_sub and apple_sub.is_valid():
- return True
+ if isinstance(sub, ManualSubscription) and sub.is_giveaway():
+ return False
- manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id)
- 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
+ return True
def in_trial(self):
"""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():
# user who has canceled can also re-subscribe
- sub: Subscription = self.get_subscription()
+ sub: Subscription = self.get_paddle_subscription()
if sub and sub.cancelled:
return True
@@ -696,7 +698,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
if self.lifetime:
channels.append("Lifetime")
- sub: Subscription = self.get_subscription()
+ sub: Subscription = self.get_paddle_subscription()
if sub:
if sub.cancelled:
channels.append(
@@ -779,7 +781,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
names = self.name.split(" ")
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 None if the subscription is already expired
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)
user = orm.relationship(User, foreign_keys=[user_id])
+ partner = orm.relationship(Partner, foreign_keys=[partner_id])
__table_args__ = (
sa.UniqueConstraint(
diff --git a/app/proton/proton_callback_handler.py b/app/proton/proton_callback_handler.py
index 930a44de..dd504f6d 100644
--- a/app/proton/proton_callback_handler.py
+++ b/app/proton/proton_callback_handler.py
@@ -3,8 +3,7 @@ from enum import Enum
from flask import url_for
from typing import Optional
-from app.db import Session
-from app.errors import LinkException, ProtonPartnerNotSetUp
+from app.errors import LinkException
from app.models import User, Partner
from app.proton.proton_client import ProtonClient, ProtonUser
from app.account_linking import (
@@ -13,20 +12,6 @@ from app.account_linking import (
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):
Login = 1
diff --git a/app/proton/utils.py b/app/proton/utils.py
index d8421076..fea2c334 100644
--- a/app/proton/utils.py
+++ b/app/proton/utils.py
@@ -1,6 +1,30 @@
+from typing import Optional
+
from app.config import CONNECT_WITH_PROTON, CONNECT_WITH_PROTON_COOKIE_NAME
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:
if CONNECT_WITH_PROTON:
diff --git a/cron.py b/cron.py
index 0c64eac4..bb2ee4e5 100644
--- a/cron.py
+++ b/cron.py
@@ -170,7 +170,7 @@ def notify_manual_sub_end():
LOG.d("%s has a lifetime licence", user)
continue
- paddle_sub: Subscription = user.get_subscription()
+ paddle_sub: Subscription = user.get_paddle_subscription()
if paddle_sub and not paddle_sub.cancelled:
LOG.d("%s has an active Paddle subscription", user)
continue
@@ -179,7 +179,7 @@ def notify_manual_sub_end():
# user can have a (free) manual subscription but has taken a paid subscription via
# Paddle, Coinbase or Apple since then
if manual_sub.is_giveaway:
- if user.get_subscription():
+ if user.get_paddle_subscription():
LOG.d("%s has a active Paddle subscription", user)
continue
diff --git a/init_app.py b/init_app.py
index 331b8f38..39ab099c 100644
--- a/init_app.py
+++ b/init_app.py
@@ -6,7 +6,7 @@ from app.db import Session
from app.log import LOG
from app.models import Mailbox, Contact, SLDomain, Partner
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
diff --git a/templates/emails/transactional/double-subscription-partner.html b/templates/emails/transactional/double-subscription-partner.html
new file mode 100644
index 00000000..e8a379d6
--- /dev/null
+++ b/templates/emails/transactional/double-subscription-partner.html
@@ -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.
+ {% 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.
+ You can find more info here.
+ {% 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.
+ {% endcall %}
+
+ {% call text() %}
+ Best,
+ SimpleLogin team.
+ {% endcall %}
+{% endblock %}
diff --git a/templates/emails/transactional/double-subscription-partner.txt.jinja2 b/templates/emails/transactional/double-subscription-partner.txt.jinja2
new file mode 100644
index 00000000..9ad89171
--- /dev/null
+++ b/templates/emails/transactional/double-subscription-partner.txt.jinja2
@@ -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 %}
diff --git a/tests/email_tests/test_rate_limit.py b/tests/email_tests/test_rate_limit.py
index e08573c7..b5f464a2 100644
--- a/tests/email_tests/test_rate_limit.py
+++ b/tests/email_tests/test_rate_limit.py
@@ -1,3 +1,5 @@
+import random
+
from app.config import (
MAX_ACTIVITY_DURING_MINUTE_PER_ALIAS,
MAX_ACTIVITY_DURING_MINUTE_PER_MAILBOX,
@@ -88,11 +90,12 @@ def test_rate_limited_reply_phase(flask_client):
alias = Alias.create_new_random(user)
Session.commit()
+ reply_email = f"reply-{random.random()}@sl.local"
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact@example.com",
- reply_email="rep@sl.local",
+ reply_email=reply_email,
)
Session.commit()
for _ in range(MAX_ACTIVITY_DURING_MINUTE_PER_ALIAS + 1):
@@ -103,4 +106,4 @@ def test_rate_limited_reply_phase(flask_client):
)
Session.commit()
- assert rate_limited_reply_phase("rep@sl.local")
+ assert rate_limited_reply_phase(reply_email)
diff --git a/tests/proton/test_proton_callback_handler.py b/tests/proton/test_proton_callback_handler.py
index 752a874c..fd7f8bbd 100644
--- a/tests/proton/test_proton_callback_handler.py
+++ b/tests/proton/test_proton_callback_handler.py
@@ -6,10 +6,10 @@ from app.account_linking import (
from app.proton.proton_client import ProtonClient, UserInformation
from app.proton.proton_callback_handler import (
ProtonCallbackHandler,
- get_proton_partner,
generate_account_not_allowed_to_log_in,
)
from app.models import User, PartnerUser
+from app.proton.utils import get_proton_partner
from app.utils import random_string
from typing import Optional
from tests.utils import random_email
diff --git a/tests/test_account_linking.py b/tests/test_account_linking.py
index 5cebb108..c71804dc 100644
--- a/tests/test_account_linking.py
+++ b/tests/test_account_linking.py
@@ -1,6 +1,10 @@
+import random
+
+import arrow
import pytest
from arrow import Arrow
+from app import config
from app.account_linking import (
process_link_case,
process_login_case,
@@ -13,11 +17,13 @@ from app.account_linking import (
SLPlanType,
PartnerLinkRequest,
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.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 tests.utils import random_email
@@ -320,3 +326,39 @@ def test_ensure_partner_user_exists_for_user_raises_exception_when_linked_to_ano
user,
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
diff --git a/tests/test_models.py b/tests/test_models.py
index 586eb15c..8fe4ea6a 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -278,12 +278,12 @@ def test_user_get_subscription_grace_period(flask_client):
commit=True,
)
- assert user.get_subscription() is not None
+ assert user.get_paddle_subscription() is not None
sub.next_bill_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):