示例#1
0
def poll_apple_subscription():
    """Poll Apple API to update AppleSubscription"""
    # todo: only near the end of the subscription
    for apple_sub in AppleSubscription.all():
        user = apple_sub.user
        verify_receipt(apple_sub.receipt_data, user, APPLE_API_SECRET)
        verify_receipt(apple_sub.receipt_data, user, MACAPP_APPLE_API_SECRET)

    LOG.d("Finish poll_apple_subscription")
示例#2
0
def compute_metric2() -> Metric2:
    now = arrow.now()
    _24h_ago = now.shift(days=-1)

    nb_referred_user_paid = 0
    for user in User.filter(User.referral_id.isnot(None)):
        if user.is_paid():
            nb_referred_user_paid += 1

    return Metric2.create(
        date=now,
        # user stats
        nb_user=User.count(),
        nb_activated_user=User.filter_by(activated=True).count(),
        # subscription stats
        nb_premium=Subscription.filter(
            Subscription.cancelled.is_(False)).count(),
        nb_cancelled_premium=Subscription.filter(
            Subscription.cancelled.is_(True)).count(),
        # todo: filter by expires_date > now
        nb_apple_premium=AppleSubscription.count(),
        nb_manual_premium=ManualSubscription.filter(
            ManualSubscription.end_at > now,
            ManualSubscription.is_giveaway.is_(False),
        ).count(),
        nb_coinbase_premium=CoinbaseSubscription.filter(
            CoinbaseSubscription.end_at > now).count(),
        # referral stats
        nb_referred_user=User.filter(User.referral_id.isnot(None)).count(),
        nb_referred_user_paid=nb_referred_user_paid,
        nb_alias=Alias.count(),
        # email log stats
        nb_forward_last_24h=EmailLog.filter(
            EmailLog.created_at > _24h_ago).filter_by(bounced=False,
                                                      is_spam=False,
                                                      is_reply=False,
                                                      blocked=False).count(),
        nb_bounced_last_24h=EmailLog.filter(
            EmailLog.created_at > _24h_ago).filter_by(bounced=True).count(),
        nb_total_bounced_last_24h=Bounce.filter(
            Bounce.created_at > _24h_ago).count(),
        nb_reply_last_24h=EmailLog.filter(
            EmailLog.created_at > _24h_ago).filter_by(is_reply=True).count(),
        nb_block_last_24h=EmailLog.filter(
            EmailLog.created_at > _24h_ago).filter_by(blocked=True).count(),
        # other stats
        nb_verified_custom_domain=CustomDomain.filter_by(
            verified=True).count(),
        nb_subdomain=CustomDomain.filter_by(is_sl_subdomain=True).count(),
        nb_directory=Directory.count(),
        nb_deleted_directory=DeletedDirectory.count(),
        nb_deleted_subdomain=DeletedSubdomain.count(),
        nb_app=Client.count(),
        commit=True,
    )
示例#3
0
def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
    for user in User.filter(User.id.in_(ids)).all():
        if user.lifetime:
            flash(f"user {user} already has a lifetime license", "warning")
            continue

        sub: Subscription = user.get_subscription()
        if sub and not sub.cancelled:
            flash(
                f"user {user} already has a Paddle license, they have to cancel it first",
                "warning",
            )
            continue

        apple_sub: AppleSubscription = AppleSubscription.get_by(
            user_id=user.id)
        if apple_sub and apple_sub.is_valid():
            flash(
                f"user {user} already has a Apple subscription, they have to cancel it first",
                "warning",
            )
            continue

        manual_sub: ManualSubscription = ManualSubscription.get_by(
            user_id=user.id)
        if manual_sub:
            # renew existing subscription
            if manual_sub.end_at > arrow.now():
                manual_sub.end_at = manual_sub.end_at.shift(years=1)
            else:
                manual_sub.end_at = arrow.now().shift(years=1, days=1)
            Session.commit()
            flash(f"Subscription extended to {manual_sub.end_at.humanize()}",
                  "success")
            continue

        ManualSubscription.create(
            user_id=user.id,
            end_at=arrow.now().shift(years=1, days=1),
            comment=way,
            is_giveaway=is_giveaway,
            commit=True,
        )

        flash(f"New {way} manual subscription for {user} is created",
              "success")
示例#4
0
def pricing():
    if current_user.lifetime:
        flash("You already have a lifetime subscription", "error")
        return redirect(url_for("dashboard.index"))

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

    now = arrow.now()
    manual_sub: ManualSubscription = ManualSubscription.filter(
        ManualSubscription.user_id == current_user.id,
        ManualSubscription.end_at > now).first()

    coinbase_sub = CoinbaseSubscription.filter(
        CoinbaseSubscription.user_id == current_user.id,
        CoinbaseSubscription.end_at > now,
    ).first()

    apple_sub: AppleSubscription = AppleSubscription.get_by(
        user_id=current_user.id)
    if apple_sub and apple_sub.is_valid():
        flash("Please make sure to cancel your subscription on Apple first",
              "warning")

    return render_template(
        "dashboard/pricing.html",
        PADDLE_VENDOR_ID=PADDLE_VENDOR_ID,
        PADDLE_MONTHLY_PRODUCT_ID=PADDLE_MONTHLY_PRODUCT_ID,
        PADDLE_YEARLY_PRODUCT_ID=PADDLE_YEARLY_PRODUCT_ID,
        success_url=URL + "/dashboard/subscription_success",
        manual_sub=manual_sub,
        coinbase_sub=coinbase_sub,
        now=now,
    )
示例#5
0
文件: apple.py 项目: nibblehole/app
def apple_update_notification():
    """
    The "Subscription Status URL" to receive update notifications from Apple
    """
    # request.json looks like this
    # will use unified_receipt.latest_receipt_info and NOT latest_expired_receipt_info
    # more info on https://developer.apple.com/documentation/appstoreservernotifications/responsebody
    # {
    #     "unified_receipt": {
    #         "latest_receipt": "long string",
    #         "pending_renewal_info": [
    #             {
    #                 "is_in_billing_retry_period": "0",
    #                 "auto_renew_status": "0",
    #                 "original_transaction_id": "1000000654277043",
    #                 "product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
    #                 "expiration_intent": "1",
    #                 "auto_renew_product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
    #             }
    #         ],
    #         "environment": "Sandbox",
    #         "status": 0,
    #         "latest_receipt_info": [
    #             {
    #                 "expires_date_pst": "2020-04-20 21:11:57 America/Los_Angeles",
    #                 "purchase_date": "2020-04-21 03:11:57 Etc/GMT",
    #                 "purchase_date_ms": "1587438717000",
    #                 "original_purchase_date_ms": "1587420715000",
    #                 "transaction_id": "1000000654329911",
    #                 "original_transaction_id": "1000000654277043",
    #                 "quantity": "1",
    #                 "expires_date_ms": "1587442317000",
    #                 "original_purchase_date_pst": "2020-04-20 15:11:55 America/Los_Angeles",
    #                 "product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
    #                 "subscription_group_identifier": "20624274",
    #                 "web_order_line_item_id": "1000000051891577",
    #                 "expires_date": "2020-04-21 04:11:57 Etc/GMT",
    #                 "is_in_intro_offer_period": "false",
    #                 "original_purchase_date": "2020-04-20 22:11:55 Etc/GMT",
    #                 "purchase_date_pst": "2020-04-20 20:11:57 America/Los_Angeles",
    #                 "is_trial_period": "false",
    #             },
    #             {
    #                 "expires_date_pst": "2020-04-20 20:11:57 America/Los_Angeles",
    #                 "purchase_date": "2020-04-21 02:11:57 Etc/GMT",
    #                 "purchase_date_ms": "1587435117000",
    #                 "original_purchase_date_ms": "1587420715000",
    #                 "transaction_id": "1000000654313889",
    #                 "original_transaction_id": "1000000654277043",
    #                 "quantity": "1",
    #                 "expires_date_ms": "1587438717000",
    #                 "original_purchase_date_pst": "2020-04-20 15:11:55 America/Los_Angeles",
    #                 "product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
    #                 "subscription_group_identifier": "20624274",
    #                 "web_order_line_item_id": "1000000051890729",
    #                 "expires_date": "2020-04-21 03:11:57 Etc/GMT",
    #                 "is_in_intro_offer_period": "false",
    #                 "original_purchase_date": "2020-04-20 22:11:55 Etc/GMT",
    #                 "purchase_date_pst": "2020-04-20 19:11:57 America/Los_Angeles",
    #                 "is_trial_period": "false",
    #             },
    #             {
    #                 "expires_date_pst": "2020-04-20 19:11:54 America/Los_Angeles",
    #                 "purchase_date": "2020-04-21 01:11:54 Etc/GMT",
    #                 "purchase_date_ms": "1587431514000",
    #                 "original_purchase_date_ms": "1587420715000",
    #                 "transaction_id": "1000000654300800",
    #                 "original_transaction_id": "1000000654277043",
    #                 "quantity": "1",
    #                 "expires_date_ms": "1587435114000",
    #                 "original_purchase_date_pst": "2020-04-20 15:11:55 America/Los_Angeles",
    #                 "product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
    #                 "subscription_group_identifier": "20624274",
    #                 "web_order_line_item_id": "1000000051890161",
    #                 "expires_date": "2020-04-21 02:11:54 Etc/GMT",
    #                 "is_in_intro_offer_period": "false",
    #                 "original_purchase_date": "2020-04-20 22:11:55 Etc/GMT",
    #                 "purchase_date_pst": "2020-04-20 18:11:54 America/Los_Angeles",
    #                 "is_trial_period": "false",
    #             },
    #             {
    #                 "expires_date_pst": "2020-04-20 18:11:54 America/Los_Angeles",
    #                 "purchase_date": "2020-04-21 00:11:54 Etc/GMT",
    #                 "purchase_date_ms": "1587427914000",
    #                 "original_purchase_date_ms": "1587420715000",
    #                 "transaction_id": "1000000654293615",
    #                 "original_transaction_id": "1000000654277043",
    #                 "quantity": "1",
    #                 "expires_date_ms": "1587431514000",
    #                 "original_purchase_date_pst": "2020-04-20 15:11:55 America/Los_Angeles",
    #                 "product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
    #                 "subscription_group_identifier": "20624274",
    #                 "web_order_line_item_id": "1000000051889539",
    #                 "expires_date": "2020-04-21 01:11:54 Etc/GMT",
    #                 "is_in_intro_offer_period": "false",
    #                 "original_purchase_date": "2020-04-20 22:11:55 Etc/GMT",
    #                 "purchase_date_pst": "2020-04-20 17:11:54 America/Los_Angeles",
    #                 "is_trial_period": "false",
    #             },
    #             {
    #                 "expires_date_pst": "2020-04-20 17:11:54 America/Los_Angeles",
    #                 "purchase_date": "2020-04-20 23:11:54 Etc/GMT",
    #                 "purchase_date_ms": "1587424314000",
    #                 "original_purchase_date_ms": "1587420715000",
    #                 "transaction_id": "1000000654285464",
    #                 "original_transaction_id": "1000000654277043",
    #                 "quantity": "1",
    #                 "expires_date_ms": "1587427914000",
    #                 "original_purchase_date_pst": "2020-04-20 15:11:55 America/Los_Angeles",
    #                 "product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
    #                 "subscription_group_identifier": "20624274",
    #                 "web_order_line_item_id": "1000000051888827",
    #                 "expires_date": "2020-04-21 00:11:54 Etc/GMT",
    #                 "is_in_intro_offer_period": "false",
    #                 "original_purchase_date": "2020-04-20 22:11:55 Etc/GMT",
    #                 "purchase_date_pst": "2020-04-20 16:11:54 America/Los_Angeles",
    #                 "is_trial_period": "false",
    #             },
    #             {
    #                 "expires_date_pst": "2020-04-20 16:11:54 America/Los_Angeles",
    #                 "purchase_date": "2020-04-20 22:11:54 Etc/GMT",
    #                 "purchase_date_ms": "1587420714000",
    #                 "original_purchase_date_ms": "1587420715000",
    #                 "transaction_id": "1000000654277043",
    #                 "original_transaction_id": "1000000654277043",
    #                 "quantity": "1",
    #                 "expires_date_ms": "1587424314000",
    #                 "original_purchase_date_pst": "2020-04-20 15:11:55 America/Los_Angeles",
    #                 "product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
    #                 "subscription_group_identifier": "20624274",
    #                 "web_order_line_item_id": "1000000051888825",
    #                 "expires_date": "2020-04-20 23:11:54 Etc/GMT",
    #                 "is_in_intro_offer_period": "false",
    #                 "original_purchase_date": "2020-04-20 22:11:55 Etc/GMT",
    #                 "purchase_date_pst": "2020-04-20 15:11:54 America/Los_Angeles",
    #                 "is_trial_period": "false",
    #             },
    #         ],
    #     },
    #     "auto_renew_status_change_date": "2020-04-21 04:11:33 Etc/GMT",
    #     "environment": "Sandbox",
    #     "auto_renew_status": "false",
    #     "auto_renew_status_change_date_pst": "2020-04-20 21:11:33 America/Los_Angeles",
    #     "latest_expired_receipt": "long string",
    #     "latest_expired_receipt_info": {
    #         "original_purchase_date_pst": "2020-04-20 15:11:55 America/Los_Angeles",
    #         "quantity": "1",
    #         "subscription_group_identifier": "20624274",
    #         "unique_vendor_identifier": "4C4DF6BA-DE2A-4737-9A68-5992338886DC",
    #         "original_purchase_date_ms": "1587420715000",
    #         "expires_date_formatted": "2020-04-21 04:11:57 Etc/GMT",
    #         "is_in_intro_offer_period": "false",
    #         "purchase_date_ms": "1587438717000",
    #         "expires_date_formatted_pst": "2020-04-20 21:11:57 America/Los_Angeles",
    #         "is_trial_period": "false",
    #         "item_id": "1508744966",
    #         "unique_identifier": "b55fc3dcc688e979115af0697a0195be78be7cbd",
    #         "original_transaction_id": "1000000654277043",
    #         "expires_date": "1587442317000",
    #         "transaction_id": "1000000654329911",
    #         "bvrs": "3",
    #         "web_order_line_item_id": "1000000051891577",
    #         "version_external_identifier": "834289833",
    #         "bid": "io.simplelogin.ios-app",
    #         "product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
    #         "purchase_date": "2020-04-21 03:11:57 Etc/GMT",
    #         "purchase_date_pst": "2020-04-20 20:11:57 America/Los_Angeles",
    #         "original_purchase_date": "2020-04-20 22:11:55 Etc/GMT",
    #     },
    #     "password": "******",
    #     "auto_renew_status_change_date_ms": "1587442293000",
    #     "auto_renew_product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
    #     "notification_type": "DID_CHANGE_RENEWAL_STATUS",
    # }
    LOG.debug("request for /api/apple/update_notification")
    data = request.get_json()
    if not (
        data
        and data.get("unified_receipt")
        and data["unified_receipt"].get("latest_receipt_info")
    ):
        LOG.d("Invalid data %s", data)
        return jsonify(error="Empty Response"), 400

    transactions = data["unified_receipt"]["latest_receipt_info"]

    # dict of original_transaction_id and transaction
    latest_transactions = {}

    for transaction in transactions:
        original_transaction_id = transaction["original_transaction_id"]
        if not latest_transactions.get(original_transaction_id):
            latest_transactions[original_transaction_id] = transaction

        if (
            transaction["expires_date_ms"]
            > latest_transactions[original_transaction_id]["expires_date_ms"]
        ):
            latest_transactions[original_transaction_id] = transaction

    for original_transaction_id, transaction in latest_transactions.items():
        expires_date = arrow.get(int(transaction["expires_date_ms"]) / 1000)
        plan = (
            PlanEnum.monthly
            if transaction["product_id"]
            in (_MONTHLY_PRODUCT_ID, _MACAPP_MONTHLY_PRODUCT_ID)
            else PlanEnum.yearly
        )

        apple_sub: AppleSubscription = AppleSubscription.get_by(
            original_transaction_id=original_transaction_id
        )

        if apple_sub:
            user = apple_sub.user
            LOG.d(
                "Update AppleSubscription for user %s, expired at %s, plan %s",
                user,
                expires_date,
                plan,
            )
            apple_sub.receipt_data = data["unified_receipt"]["latest_receipt"]
            apple_sub.expires_date = expires_date
            apple_sub.plan = plan
            db.session.commit()
            return jsonify(ok=True), 200
        else:
            LOG.warning(
                "No existing AppleSub for original_transaction_id %s",
                original_transaction_id,
            )
            LOG.d("request data %s", data)
            return jsonify(ok=False), 400
示例#6
0
文件: apple.py 项目: nibblehole/app
def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
    """Call verifyReceipt endpoint and create/update AppleSubscription table
    Call the production URL for verifyReceipt first,
    and proceed to verify with the sandbox URL if receive a 21007 status code.

    Return AppleSubscription object if success

    https://developer.apple.com/documentation/appstorereceipts/verifyreceipt
    """
    LOG.d("start verify_receipt")
    try:
        r = requests.post(
            _PROD_URL, json={"receipt-data": receipt_data, "password": password}
        )
    except ConnectionError:
        LOG.warning("cannot call Apple server %s", _PROD_URL)
        return None

    if r.status_code >= 500:
        LOG.warning("Apple server error, response:%s %s", r, r.content)
        return None

    if r.json() == {"status": 21007}:
        # try sandbox_url
        LOG.warning("Use the sandbox url instead")
        r = requests.post(
            _SANDBOX_URL,
            json={"receipt-data": receipt_data, "password": password},
        )

    data = r.json()
    # data has the following format
    # {
    #     "status": 0,
    #     "environment": "Sandbox",
    #     "receipt": {
    #         "receipt_type": "ProductionSandbox",
    #         "adam_id": 0,
    #         "app_item_id": 0,
    #         "bundle_id": "io.simplelogin.ios-app",
    #         "application_version": "2",
    #         "download_id": 0,
    #         "version_external_identifier": 0,
    #         "receipt_creation_date": "2020-04-18 16:36:34 Etc/GMT",
    #         "receipt_creation_date_ms": "1587227794000",
    #         "receipt_creation_date_pst": "2020-04-18 09:36:34 America/Los_Angeles",
    #         "request_date": "2020-04-18 16:46:36 Etc/GMT",
    #         "request_date_ms": "1587228396496",
    #         "request_date_pst": "2020-04-18 09:46:36 America/Los_Angeles",
    #         "original_purchase_date": "2013-08-01 07:00:00 Etc/GMT",
    #         "original_purchase_date_ms": "1375340400000",
    #         "original_purchase_date_pst": "2013-08-01 00:00:00 America/Los_Angeles",
    #         "original_application_version": "1.0",
    #         "in_app": [
    #             {
    #                 "quantity": "1",
    #                 "product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
    #                 "transaction_id": "1000000653584474",
    #                 "original_transaction_id": "1000000653584474",
    #                 "purchase_date": "2020-04-18 16:27:42 Etc/GMT",
    #                 "purchase_date_ms": "1587227262000",
    #                 "purchase_date_pst": "2020-04-18 09:27:42 America/Los_Angeles",
    #                 "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT",
    #                 "original_purchase_date_ms": "1587227264000",
    #                 "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles",
    #                 "expires_date": "2020-04-18 16:32:42 Etc/GMT",
    #                 "expires_date_ms": "1587227562000",
    #                 "expires_date_pst": "2020-04-18 09:32:42 America/Los_Angeles",
    #                 "web_order_line_item_id": "1000000051847459",
    #                 "is_trial_period": "false",
    #                 "is_in_intro_offer_period": "false",
    #             },
    #             {
    #                 "quantity": "1",
    #                 "product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
    #                 "transaction_id": "1000000653584861",
    #                 "original_transaction_id": "1000000653584474",
    #                 "purchase_date": "2020-04-18 16:32:42 Etc/GMT",
    #                 "purchase_date_ms": "1587227562000",
    #                 "purchase_date_pst": "2020-04-18 09:32:42 America/Los_Angeles",
    #                 "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT",
    #                 "original_purchase_date_ms": "1587227264000",
    #                 "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles",
    #                 "expires_date": "2020-04-18 16:37:42 Etc/GMT",
    #                 "expires_date_ms": "1587227862000",
    #                 "expires_date_pst": "2020-04-18 09:37:42 America/Los_Angeles",
    #                 "web_order_line_item_id": "1000000051847461",
    #                 "is_trial_period": "false",
    #                 "is_in_intro_offer_period": "false",
    #             },
    #         ],
    #     },
    #     "latest_receipt_info": [
    #         {
    #             "quantity": "1",
    #             "product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
    #             "transaction_id": "1000000653584474",
    #             "original_transaction_id": "1000000653584474",
    #             "purchase_date": "2020-04-18 16:27:42 Etc/GMT",
    #             "purchase_date_ms": "1587227262000",
    #             "purchase_date_pst": "2020-04-18 09:27:42 America/Los_Angeles",
    #             "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT",
    #             "original_purchase_date_ms": "1587227264000",
    #             "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles",
    #             "expires_date": "2020-04-18 16:32:42 Etc/GMT",
    #             "expires_date_ms": "1587227562000",
    #             "expires_date_pst": "2020-04-18 09:32:42 America/Los_Angeles",
    #             "web_order_line_item_id": "1000000051847459",
    #             "is_trial_period": "false",
    #             "is_in_intro_offer_period": "false",
    #             "subscription_group_identifier": "20624274",
    #         },
    #         {
    #             "quantity": "1",
    #             "product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
    #             "transaction_id": "1000000653584861",
    #             "original_transaction_id": "1000000653584474",
    #             "purchase_date": "2020-04-18 16:32:42 Etc/GMT",
    #             "purchase_date_ms": "1587227562000",
    #             "purchase_date_pst": "2020-04-18 09:32:42 America/Los_Angeles",
    #             "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT",
    #             "original_purchase_date_ms": "1587227264000",
    #             "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles",
    #             "expires_date": "2020-04-18 16:37:42 Etc/GMT",
    #             "expires_date_ms": "1587227862000",
    #             "expires_date_pst": "2020-04-18 09:37:42 America/Los_Angeles",
    #             "web_order_line_item_id": "1000000051847461",
    #             "is_trial_period": "false",
    #             "is_in_intro_offer_period": "false",
    #             "subscription_group_identifier": "20624274",
    #         },
    #         {
    #             "quantity": "1",
    #             "product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
    #             "transaction_id": "1000000653585235",
    #             "original_transaction_id": "1000000653584474",
    #             "purchase_date": "2020-04-18 16:38:16 Etc/GMT",
    #             "purchase_date_ms": "1587227896000",
    #             "purchase_date_pst": "2020-04-18 09:38:16 America/Los_Angeles",
    #             "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT",
    #             "original_purchase_date_ms": "1587227264000",
    #             "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles",
    #             "expires_date": "2020-04-18 16:43:16 Etc/GMT",
    #             "expires_date_ms": "1587228196000",
    #             "expires_date_pst": "2020-04-18 09:43:16 America/Los_Angeles",
    #             "web_order_line_item_id": "1000000051847500",
    #             "is_trial_period": "false",
    #             "is_in_intro_offer_period": "false",
    #             "subscription_group_identifier": "20624274",
    #         },
    #         {
    #             "quantity": "1",
    #             "product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
    #             "transaction_id": "1000000653585760",
    #             "original_transaction_id": "1000000653584474",
    #             "purchase_date": "2020-04-18 16:44:25 Etc/GMT",
    #             "purchase_date_ms": "1587228265000",
    #             "purchase_date_pst": "2020-04-18 09:44:25 America/Los_Angeles",
    #             "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT",
    #             "original_purchase_date_ms": "1587227264000",
    #             "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles",
    #             "expires_date": "2020-04-18 16:49:25 Etc/GMT",
    #             "expires_date_ms": "1587228565000",
    #             "expires_date_pst": "2020-04-18 09:49:25 America/Los_Angeles",
    #             "web_order_line_item_id": "1000000051847566",
    #             "is_trial_period": "false",
    #             "is_in_intro_offer_period": "false",
    #             "subscription_group_identifier": "20624274",
    #         },
    #     ],
    #     "latest_receipt": "very long string",
    #     "pending_renewal_info": [
    #         {
    #             "auto_renew_product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
    #             "original_transaction_id": "1000000653584474",
    #             "product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
    #             "auto_renew_status": "1",
    #         }
    #     ],
    # }

    if data["status"] != 0:
        LOG.warning(
            "verifyReceipt status !=0, probably invalid receipt. User %s",
            user,
        )
        return None

    # each item in data["receipt"]["in_app"] has the following format
    # {
    #     "quantity": "1",
    #     "product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
    #     "transaction_id": "1000000653584474",
    #     "original_transaction_id": "1000000653584474",
    #     "purchase_date": "2020-04-18 16:27:42 Etc/GMT",
    #     "purchase_date_ms": "1587227262000",
    #     "purchase_date_pst": "2020-04-18 09:27:42 America/Los_Angeles",
    #     "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT",
    #     "original_purchase_date_ms": "1587227264000",
    #     "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles",
    #     "expires_date": "2020-04-18 16:32:42 Etc/GMT",
    #     "expires_date_ms": "1587227562000",
    #     "expires_date_pst": "2020-04-18 09:32:42 America/Los_Angeles",
    #     "web_order_line_item_id": "1000000051847459",
    #     "is_trial_period": "false",
    #     "is_in_intro_offer_period": "false",
    # }
    transactions = data["receipt"]["in_app"]
    if not transactions:
        LOG.warning("Empty transactions in data %s", data)
        return None

    latest_transaction = max(transactions, key=lambda t: int(t["expires_date_ms"]))
    original_transaction_id = latest_transaction["original_transaction_id"]
    expires_date = arrow.get(int(latest_transaction["expires_date_ms"]) / 1000)
    plan = (
        PlanEnum.monthly
        if latest_transaction["product_id"] == _MONTHLY_PRODUCT_ID
        else PlanEnum.yearly
    )

    apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=user.id)

    if apple_sub:
        LOG.d(
            "Update AppleSubscription for user %s, expired at %s, plan %s",
            user,
            expires_date,
            plan,
        )
        apple_sub.receipt_data = receipt_data
        apple_sub.expires_date = expires_date
        apple_sub.original_transaction_id = original_transaction_id
        apple_sub.plan = plan
    else:
        # the same original_transaction_id has been used on another account
        if AppleSubscription.get_by(original_transaction_id=original_transaction_id):
            LOG.exception("Same Apple Sub has been used before, current user %s", user)
            return None

        LOG.d(
            "Create new AppleSubscription for user %s, expired at %s, plan %s",
            user,
            expires_date,
            plan,
        )
        apple_sub = AppleSubscription.create(
            user_id=user.id,
            receipt_data=receipt_data,
            expires_date=expires_date,
            original_transaction_id=original_transaction_id,
            plan=plan,
        )

    db.session.commit()

    return apple_sub
示例#7
0
def setting():
    form = SettingForm()
    promo_form = PromoCodeForm()
    change_email_form = ChangeEmailForm()

    email_change = EmailChange.get_by(user_id=current_user.id)
    if email_change:
        pending_email = email_change.new_email
    else:
        pending_email = None

    if request.method == "POST":
        if request.form.get("form-name") == "update-email":
            if change_email_form.validate():
                # whether user can proceed with the email update
                new_email_valid = True
                if (sanitize_email(change_email_form.email.data) !=
                        current_user.email and not pending_email):
                    new_email = sanitize_email(change_email_form.email.data)

                    # check if this email is not already used
                    if personal_email_already_used(new_email) or Alias.get_by(
                            email=new_email):
                        flash(f"Email {new_email} already used", "error")
                        new_email_valid = False
                    elif not email_can_be_used_as_mailbox(new_email):
                        flash(
                            "You cannot use this email address as your personal inbox.",
                            "error",
                        )
                        new_email_valid = False
                    # a pending email change with the same email exists from another user
                    elif EmailChange.get_by(new_email=new_email):
                        other_email_change: EmailChange = EmailChange.get_by(
                            new_email=new_email)
                        LOG.warning(
                            "Another user has a pending %s with the same email address. Current user:%s",
                            other_email_change,
                            current_user,
                        )

                        if other_email_change.is_expired():
                            LOG.d("delete the expired email change %s",
                                  other_email_change)
                            EmailChange.delete(other_email_change.id)
                            db.session.commit()
                        else:
                            flash(
                                "You cannot use this email address as your personal inbox.",
                                "error",
                            )
                            new_email_valid = False

                    if new_email_valid:
                        email_change = EmailChange.create(
                            user_id=current_user.id,
                            code=random_string(
                                60),  # todo: make sure the code is unique
                            new_email=new_email,
                        )
                        db.session.commit()
                        send_change_email_confirmation(current_user,
                                                       email_change)
                        flash(
                            "A confirmation email is on the way, please check your inbox",
                            "success",
                        )
                        return redirect(url_for("dashboard.setting"))
        if request.form.get("form-name") == "update-profile":
            if form.validate():
                profile_updated = False
                # update user info
                if form.name.data != current_user.name:
                    current_user.name = form.name.data
                    db.session.commit()
                    profile_updated = True

                if form.profile_picture.data:
                    file_path = random_string(30)
                    file = File.create(user_id=current_user.id, path=file_path)

                    s3.upload_from_bytesio(
                        file_path, BytesIO(form.profile_picture.data.read()))

                    db.session.flush()
                    LOG.d("upload file %s to s3", file)

                    current_user.profile_picture_id = file.id
                    db.session.commit()
                    profile_updated = True

                if profile_updated:
                    flash("Your profile has been updated", "success")
                    return redirect(url_for("dashboard.setting"))

        elif request.form.get("form-name") == "change-password":
            flash(
                "You are going to receive an email containing instructions to change your password",
                "success",
            )
            send_reset_password_email(current_user)
            return redirect(url_for("dashboard.setting"))

        elif request.form.get("form-name") == "notification-preference":
            choose = request.form.get("notification")
            if choose == "on":
                current_user.notification = True
            else:
                current_user.notification = False
            db.session.commit()
            flash("Your notification preference has been updated", "success")
            return redirect(url_for("dashboard.setting"))

        elif request.form.get("form-name") == "delete-account":
            # Schedule delete account job
            LOG.warning("schedule delete account job for %s", current_user)
            Job.create(
                name=JOB_DELETE_ACCOUNT,
                payload={"user_id": current_user.id},
                run_at=arrow.now(),
                commit=True,
            )

            flash(
                "Your account deletion has been scheduled. "
                "You'll receive an email when the deletion is finished",
                "success",
            )
            return redirect(url_for("dashboard.setting"))

        elif request.form.get("form-name") == "change-alias-generator":
            scheme = int(request.form.get("alias-generator-scheme"))
            if AliasGeneratorEnum.has_value(scheme):
                current_user.alias_generator = scheme
                db.session.commit()
            flash("Your preference has been updated", "success")
            return redirect(url_for("dashboard.setting"))

        elif request.form.get(
                "form-name") == "change-random-alias-default-domain":
            default_domain = request.form.get("random-alias-default-domain")

            if default_domain:
                sl_domain: SLDomain = SLDomain.get_by(domain=default_domain)
                if sl_domain:
                    if sl_domain.premium_only and not current_user.is_premium(
                    ):
                        flash("You cannot use this domain", "error")
                        return redirect(url_for("dashboard.setting"))

                    current_user.default_alias_public_domain_id = sl_domain.id
                    current_user.default_alias_custom_domain_id = None
                else:
                    custom_domain = CustomDomain.get_by(domain=default_domain)
                    if custom_domain:
                        # sanity check
                        if (custom_domain.user_id != current_user.id
                                or not custom_domain.verified):
                            LOG.exception("%s cannot use domain %s",
                                          current_user, default_domain)
                        else:
                            current_user.default_alias_custom_domain_id = (
                                custom_domain.id)
                            current_user.default_alias_public_domain_id = None

            else:
                current_user.default_alias_custom_domain_id = None
                current_user.default_alias_public_domain_id = None

            db.session.commit()
            flash("Your preference has been updated", "success")
            return redirect(url_for("dashboard.setting"))

        elif request.form.get("form-name") == "change-sender-format":
            sender_format = int(request.form.get("sender-format"))
            if SenderFormatEnum.has_value(sender_format):
                current_user.sender_format = sender_format
                current_user.sender_format_updated_at = arrow.now()
                db.session.commit()
                flash("Your sender format preference has been updated",
                      "success")
            db.session.commit()
            return redirect(url_for("dashboard.setting"))

        elif request.form.get("form-name") == "replace-ra":
            choose = request.form.get("replace-ra")
            if choose == "on":
                current_user.replace_reverse_alias = True
            else:
                current_user.replace_reverse_alias = False
            db.session.commit()
            flash("Your preference has been updated", "success")
            return redirect(url_for("dashboard.setting"))

        elif request.form.get("form-name") == "sender-in-ra":
            choose = request.form.get("enable")
            if choose == "on":
                current_user.include_sender_in_reverse_alias = True
            else:
                current_user.include_sender_in_reverse_alias = False
            db.session.commit()
            flash("Your preference has been updated", "success")
            return redirect(url_for("dashboard.setting"))

        elif request.form.get("form-name") == "export-data":
            return redirect(url_for("api.export_data"))
        elif request.form.get("form-name") == "export-alias":
            return redirect(url_for("api.export_aliases"))

    manual_sub = ManualSubscription.get_by(user_id=current_user.id)
    apple_sub = AppleSubscription.get_by(user_id=current_user.id)
    coinbase_sub = CoinbaseSubscription.get_by(user_id=current_user.id)

    return render_template(
        "dashboard/setting.html",
        form=form,
        PlanEnum=PlanEnum,
        SenderFormatEnum=SenderFormatEnum,
        promo_form=promo_form,
        change_email_form=change_email_form,
        pending_email=pending_email,
        AliasGeneratorEnum=AliasGeneratorEnum,
        manual_sub=manual_sub,
        apple_sub=apple_sub,
        coinbase_sub=coinbase_sub,
        FIRST_ALIAS_DOMAIN=FIRST_ALIAS_DOMAIN,
    )
示例#8
0
def coupon_route():
    coupon_form = CouponForm()

    if coupon_form.validate_on_submit():
        code = coupon_form.code.data
        if LifetimeCoupon.get_by(code=code):
            LOG.d("redirect %s to lifetime page instead", current_user)
            flash("Redirect to the lifetime coupon page instead", "success")
            return redirect(url_for("dashboard.lifetime_licence"))

    # handle case user already has an active subscription via another channel (Paddle, Apple, etc)
    can_use_coupon = True

    if current_user.lifetime:
        can_use_coupon = False

    sub: Subscription = current_user.get_subscription()
    if sub:
        can_use_coupon = False

    apple_sub: AppleSubscription = AppleSubscription.get_by(
        user_id=current_user.id)
    if apple_sub and apple_sub.is_valid():
        can_use_coupon = False

    coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by(
        user_id=current_user.id)
    if coinbase_subscription and coinbase_subscription.is_active():
        can_use_coupon = False

    if coupon_form.validate_on_submit():
        code = coupon_form.code.data

        coupon: Coupon = Coupon.get_by(code=code)
        if coupon and not coupon.used:
            if coupon.expires_date and coupon.expires_date < arrow.now():
                flash(
                    f"The coupon was expired on {coupon.expires_date.humanize()}",
                    "error",
                )
                return redirect(request.url)

            coupon.used_by_user_id = current_user.id
            coupon.used = True
            Session.commit()

            manual_sub: ManualSubscription = ManualSubscription.get_by(
                user_id=current_user.id)
            if manual_sub:
                # renew existing subscription
                if manual_sub.end_at > arrow.now():
                    manual_sub.end_at = manual_sub.end_at.shift(
                        years=coupon.nb_year)
                else:
                    manual_sub.end_at = arrow.now().shift(years=coupon.nb_year,
                                                          days=1)
                Session.commit()
                flash(
                    f"Your current subscription is extended to {manual_sub.end_at.humanize()}",
                    "success",
                )
            else:
                ManualSubscription.create(
                    user_id=current_user.id,
                    end_at=arrow.now().shift(years=coupon.nb_year, days=1),
                    comment="using coupon code",
                    is_giveaway=coupon.is_giveaway,
                    commit=True,
                )
                flash(
                    f"Your account has been upgraded to Premium, thanks for your support!",
                    "success",
                )

            # notify admin
            if coupon.is_giveaway:
                subject = f"User {current_user} applies a (free) coupon"
            else:
                subject = f"User {current_user} applies a (paid, {coupon.comment or ''}) coupon"
            send_email(
                ADMIN_EMAIL,
                subject=subject,
                plaintext="",
                html="",
            )

            return redirect(url_for("dashboard.index"))

        else:
            flash(f"Code *{code}* expired or invalid", "warning")

    return render_template(
        "dashboard/coupon.html",
        coupon_form=coupon_form,
        PADDLE_VENDOR_ID=PADDLE_VENDOR_ID,
        PADDLE_COUPON_ID=PADDLE_COUPON_ID,
        can_use_coupon=can_use_coupon,
        # a coupon is only valid until this date
        # this is to avoid using the coupon to renew an account forever
        max_coupon_date=arrow.now().shift(years=1, days=-1),
    )
示例#9
0
def setting():
    form = SettingForm()
    promo_form = PromoCodeForm()
    change_email_form = ChangeEmailForm()

    email_change = EmailChange.get_by(user_id=current_user.id)
    if email_change:
        pending_email = email_change.new_email
    else:
        pending_email = None

    if request.method == "POST":
        if request.form.get("form-name") == "update-email":
            if change_email_form.validate():
                # whether user can proceed with the email update
                new_email_valid = True
                if (
                    sanitize_email(change_email_form.email.data) != current_user.email
                    and not pending_email
                ):
                    new_email = sanitize_email(change_email_form.email.data)

                    # check if this email is not already used
                    if personal_email_already_used(new_email) or Alias.get_by(
                        email=new_email
                    ):
                        flash(f"Email {new_email} already used", "error")
                        new_email_valid = False
                    elif not email_can_be_used_as_mailbox(new_email):
                        flash(
                            "You cannot use this email address as your personal inbox.",
                            "error",
                        )
                        new_email_valid = False
                    # a pending email change with the same email exists from another user
                    elif EmailChange.get_by(new_email=new_email):
                        other_email_change: EmailChange = EmailChange.get_by(
                            new_email=new_email
                        )
                        LOG.w(
                            "Another user has a pending %s with the same email address. Current user:%s",
                            other_email_change,
                            current_user,
                        )

                        if other_email_change.is_expired():
                            LOG.d(
                                "delete the expired email change %s", other_email_change
                            )
                            EmailChange.delete(other_email_change.id)
                            Session.commit()
                        else:
                            flash(
                                "You cannot use this email address as your personal inbox.",
                                "error",
                            )
                            new_email_valid = False

                    if new_email_valid:
                        email_change = EmailChange.create(
                            user_id=current_user.id,
                            code=random_string(
                                60
                            ),  # todo: make sure the code is unique
                            new_email=new_email,
                        )
                        Session.commit()
                        send_change_email_confirmation(current_user, email_change)
                        flash(
                            "A confirmation email is on the way, please check your inbox",
                            "success",
                        )
                        return redirect(url_for("dashboard.setting"))
        if request.form.get("form-name") == "update-profile":
            if form.validate():
                profile_updated = False
                # update user info
                if form.name.data != current_user.name:
                    current_user.name = form.name.data
                    Session.commit()
                    profile_updated = True

                if form.profile_picture.data:
                    file_path = random_string(30)
                    file = File.create(user_id=current_user.id, path=file_path)

                    s3.upload_from_bytesio(
                        file_path, BytesIO(form.profile_picture.data.read())
                    )

                    Session.flush()
                    LOG.d("upload file %s to s3", file)

                    current_user.profile_picture_id = file.id
                    Session.commit()
                    profile_updated = True

                if profile_updated:
                    flash("Your profile has been updated", "success")
                    return redirect(url_for("dashboard.setting"))

        elif request.form.get("form-name") == "change-password":
            flash(
                "You are going to receive an email containing instructions to change your password",
                "success",
            )
            send_reset_password_email(current_user)
            return redirect(url_for("dashboard.setting"))

        elif request.form.get("form-name") == "notification-preference":
            choose = request.form.get("notification")
            if choose == "on":
                current_user.notification = True
            else:
                current_user.notification = False
            Session.commit()
            flash("Your notification preference has been updated", "success")
            return redirect(url_for("dashboard.setting"))

        elif request.form.get("form-name") == "change-alias-generator":
            scheme = int(request.form.get("alias-generator-scheme"))
            if AliasGeneratorEnum.has_value(scheme):
                current_user.alias_generator = scheme
                Session.commit()
            flash("Your preference has been updated", "success")
            return redirect(url_for("dashboard.setting"))

        elif request.form.get("form-name") == "change-random-alias-default-domain":
            default_domain = request.form.get("random-alias-default-domain")

            if default_domain:
                sl_domain: SLDomain = SLDomain.get_by(domain=default_domain)
                if sl_domain:
                    if sl_domain.premium_only and not current_user.is_premium():
                        flash("You cannot use this domain", "error")
                        return redirect(url_for("dashboard.setting"))

                    current_user.default_alias_public_domain_id = sl_domain.id
                    current_user.default_alias_custom_domain_id = None
                else:
                    custom_domain = CustomDomain.get_by(domain=default_domain)
                    if custom_domain:
                        # sanity check
                        if (
                            custom_domain.user_id != current_user.id
                            or not custom_domain.verified
                        ):
                            LOG.w(
                                "%s cannot use domain %s", current_user, custom_domain
                            )
                            flash(f"Domain {default_domain} can't be used", "error")
                            return redirect(request.url)
                        else:
                            current_user.default_alias_custom_domain_id = (
                                custom_domain.id
                            )
                            current_user.default_alias_public_domain_id = None

            else:
                current_user.default_alias_custom_domain_id = None
                current_user.default_alias_public_domain_id = None

            Session.commit()
            flash("Your preference has been updated", "success")
            return redirect(url_for("dashboard.setting"))

        elif request.form.get("form-name") == "random-alias-suffix":
            scheme = int(request.form.get("random-alias-suffix-generator"))
            if AliasSuffixEnum.has_value(scheme):
                current_user.random_alias_suffix = scheme
                Session.commit()
            flash("Your preference has been updated", "success")
            return redirect(url_for("dashboard.setting"))

        elif request.form.get("form-name") == "change-sender-format":
            sender_format = int(request.form.get("sender-format"))
            if SenderFormatEnum.has_value(sender_format):
                current_user.sender_format = sender_format
                current_user.sender_format_updated_at = arrow.now()
                Session.commit()
                flash("Your sender format preference has been updated", "success")
            Session.commit()
            return redirect(url_for("dashboard.setting"))

        elif request.form.get("form-name") == "replace-ra":
            choose = request.form.get("replace-ra")
            if choose == "on":
                current_user.replace_reverse_alias = True
            else:
                current_user.replace_reverse_alias = False
            Session.commit()
            flash("Your preference has been updated", "success")
            return redirect(url_for("dashboard.setting"))

        elif request.form.get("form-name") == "sender-in-ra":
            choose = request.form.get("enable")
            if choose == "on":
                current_user.include_sender_in_reverse_alias = True
            else:
                current_user.include_sender_in_reverse_alias = False
            Session.commit()
            flash("Your preference has been updated", "success")
            return redirect(url_for("dashboard.setting"))

        elif request.form.get("form-name") == "expand-alias-info":
            choose = request.form.get("enable")
            if choose == "on":
                current_user.expand_alias_info = True
            else:
                current_user.expand_alias_info = False
            Session.commit()
            flash("Your preference has been updated", "success")
            return redirect(url_for("dashboard.setting"))
        elif request.form.get("form-name") == "ignore-loop-email":
            choose = request.form.get("enable")
            if choose == "on":
                current_user.ignore_loop_email = True
            else:
                current_user.ignore_loop_email = False
            Session.commit()
            flash("Your preference has been updated", "success")
            return redirect(url_for("dashboard.setting"))
        elif request.form.get("form-name") == "one-click-unsubscribe":
            choose = request.form.get("enable")
            if choose == "on":
                current_user.one_click_unsubscribe_block_sender = True
            else:
                current_user.one_click_unsubscribe_block_sender = False
            Session.commit()
            flash("Your preference has been updated", "success")
            return redirect(url_for("dashboard.setting"))
        elif request.form.get("form-name") == "include_website_in_one_click_alias":
            choose = request.form.get("enable")
            if choose == "on":
                current_user.include_website_in_one_click_alias = True
            else:
                current_user.include_website_in_one_click_alias = False
            Session.commit()
            flash("Your preference has been updated", "success")
            return redirect(url_for("dashboard.setting"))
        elif request.form.get("form-name") == "change-blocked-behaviour":
            choose = request.form.get("blocked-behaviour")
            if choose == str(BlockBehaviourEnum.return_2xx.value):
                current_user.block_behaviour = BlockBehaviourEnum.return_2xx.name
            elif choose == str(BlockBehaviourEnum.return_5xx.value):
                current_user.block_behaviour = BlockBehaviourEnum.return_5xx.name
            else:
                flash("There was an error. Please try again", "warning")
                return redirect(url_for("dashboard.setting"))
            Session.commit()
            flash("Your preference has been updated", "success")
        elif request.form.get("form-name") == "sender-header":
            choose = request.form.get("enable")
            if choose == "on":
                current_user.include_header_email_header = True
            else:
                current_user.include_header_email_header = False
            Session.commit()
            flash("Your preference has been updated", "success")
            return redirect(url_for("dashboard.setting"))
        elif request.form.get("form-name") == "export-data":
            return redirect(url_for("api.export_data"))
        elif request.form.get("form-name") == "export-alias":
            return redirect(url_for("api.export_aliases"))

    manual_sub = ManualSubscription.get_by(user_id=current_user.id)
    apple_sub = AppleSubscription.get_by(user_id=current_user.id)
    coinbase_sub = CoinbaseSubscription.get_by(user_id=current_user.id)

    return render_template(
        "dashboard/setting.html",
        form=form,
        PlanEnum=PlanEnum,
        SenderFormatEnum=SenderFormatEnum,
        BlockBehaviourEnum=BlockBehaviourEnum,
        promo_form=promo_form,
        change_email_form=change_email_form,
        pending_email=pending_email,
        AliasGeneratorEnum=AliasGeneratorEnum,
        manual_sub=manual_sub,
        apple_sub=apple_sub,
        coinbase_sub=coinbase_sub,
        FIRST_ALIAS_DOMAIN=FIRST_ALIAS_DOMAIN,
        ALIAS_RAND_SUFFIX_LENGTH=ALIAS_RANDOM_SUFFIX_LENGTH,
    )
示例#10
0
def notify_manual_sub_end():
    for manual_sub in ManualSubscription.all():
        manual_sub: ManualSubscription
        need_reminder = False
        if arrow.now().shift(days=14) > manual_sub.end_at > arrow.now().shift(
                days=13):
            need_reminder = True
        elif arrow.now().shift(days=4) > manual_sub.end_at > arrow.now().shift(
                days=3):
            need_reminder = True

        user = manual_sub.user
        if user.lifetime:
            LOG.d("%s has a lifetime licence", user)
            continue

        paddle_sub: Subscription = user.get_subscription()
        if paddle_sub and not paddle_sub.cancelled:
            LOG.d("%s has an active Paddle subscription", user)
            continue

        if need_reminder:
            # 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():
                    LOG.d("%s has a active Paddle subscription", user)
                    continue

                coinbase_subscription: CoinbaseSubscription = (
                    CoinbaseSubscription.get_by(user_id=user.id))
                if coinbase_subscription and coinbase_subscription.is_active():
                    LOG.d("%s has a active Coinbase subscription", user)
                    continue

                apple_sub: AppleSubscription = AppleSubscription.get_by(
                    user_id=user.id)
                if apple_sub and apple_sub.is_valid():
                    LOG.d("%s has a active Apple subscription", user)
                    continue

            LOG.d("Remind user %s that their manual sub is ending soon", user)
            send_email(
                user.email,
                f"Your subscription will end soon",
                render(
                    "transactional/manual-subscription-end.txt",
                    user=user,
                    manual_sub=manual_sub,
                ),
                render(
                    "transactional/manual-subscription-end.html",
                    user=user,
                    manual_sub=manual_sub,
                ),
                retries=3,
            )

    extend_subscription_url = URL + "/dashboard/coinbase_checkout"
    for coinbase_subscription in CoinbaseSubscription.all():
        need_reminder = False
        if (arrow.now().shift(days=14) > coinbase_subscription.end_at >
                arrow.now().shift(days=13)):
            need_reminder = True
        elif (arrow.now().shift(days=4) > coinbase_subscription.end_at >
              arrow.now().shift(days=3)):
            need_reminder = True

        if need_reminder:
            user = coinbase_subscription.user
            LOG.d(
                "Remind user %s that their coinbase subscription is ending soon",
                user)
            send_email(
                user.email,
                "Your SimpleLogin subscription will end soon",
                render(
                    "transactional/coinbase/reminder-subscription.txt",
                    coinbase_subscription=coinbase_subscription,
                    extend_subscription_url=extend_subscription_url,
                ),
                render(
                    "transactional/coinbase/reminder-subscription.html",
                    coinbase_subscription=coinbase_subscription,
                    extend_subscription_url=extend_subscription_url,
                ),
                retries=3,
            )