From 7b965e4121f6f4ddb536635db9d54f16842bc126 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sat, 18 Apr 2020 20:47:11 +0200 Subject: [PATCH 01/10] Add APPLE_API_SECRET param --- app/config.py | 3 +++ example.env | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/config.py b/app/config.py index 7d8d8f88..7b7c31e1 100644 --- a/app/config.py +++ b/app/config.py @@ -243,3 +243,6 @@ with open(get_abs_path(DISPOSABLE_FILE_PATH), "r") as f: DISPOSABLE_EMAIL_DOMAINS = [ d for d in DISPOSABLE_EMAIL_DOMAINS if not d.startswith("#") ] + +# Used when querying info on Apple API +APPLE_API_SECRET = os.environ.get("APPLE_API_SECRET") \ No newline at end of file diff --git a/example.env b/example.env index a2747523..f4265bdb 100644 --- a/example.env +++ b/example.env @@ -122,4 +122,7 @@ FACEBOOK_CLIENT_SECRET=to_fill # LOCAL_FILE_UPLOAD=true # The landing page -# LANDING_PAGE_URL=https://simplelogin.io \ No newline at end of file +# LANDING_PAGE_URL=https://simplelogin.io + +# Used when querying info on Apple API +# APPLE_API_SECRET=secret \ No newline at end of file From b0118e615a6d22d4ce3ccc396d34cccad327f0b6 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sat, 18 Apr 2020 20:47:33 +0200 Subject: [PATCH 02/10] Add AppleSubscription model --- app/models.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/models.py b/app/models.py index 0456a352..dc5f3575 100644 --- a/app/models.py +++ b/app/models.py @@ -940,6 +940,25 @@ class ManualSubscription(db.Model, ModelMixin): user = db.relationship(User) +class AppleSubscription(db.Model, ModelMixin): + """ + For users who have subscribed via Apple in-app payment + """ + + user_id = db.Column( + db.ForeignKey(User.id, ondelete="cascade"), nullable=False, unique=True + ) + + expires_date = db.Column(ArrowType, nullable=False) + + original_transaction_id = db.Column(db.String(256), nullable=False) + receipt_data = db.Column(db.Text(), nullable=False) + + plan = db.Column(db.Enum(PlanEnum), nullable=False) + + user = db.relationship(User) + + class DeletedAlias(db.Model, ModelMixin): """Store all deleted alias to make sure they are NOT reused""" From f7f1e7f35850f111f3478116b440085cc7a6f3ac Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 19 Apr 2020 10:54:05 +0200 Subject: [PATCH 03/10] replace user.next_bill_date() by sub.next_bill_date.strftime("%Y-%m-%d") --- app/dashboard/templates/dashboard/billing.html | 2 +- app/dashboard/templates/dashboard/pricing.html | 15 ++++++++------- app/models.py | 10 ---------- cron.py | 12 ++++++++++-- .../emails/transactional/subscription-end.html | 2 +- .../emails/transactional/subscription-end.txt | 2 +- templates/header.html | 6 ++++-- 7 files changed, 25 insertions(+), 24 deletions(-) diff --git a/app/dashboard/templates/dashboard/billing.html b/app/dashboard/templates/dashboard/billing.html index d2a74a17..6c6cbfb7 100644 --- a/app/dashboard/templates/dashboard/billing.html +++ b/app/dashboard/templates/dashboard/billing.html @@ -14,7 +14,7 @@ {% if sub.cancelled %}

You are on the {{ sub.plan_name() }} plan.
- You have canceled your subscription and it will end on {{ current_user.next_bill_date() }} + You have canceled your subscription and it will end on {{ sub.next_bill_date.strftime("%Y-%m-%d") }}


diff --git a/app/dashboard/templates/dashboard/pricing.html b/app/dashboard/templates/dashboard/pricing.html index 0ce5320c..fd50e965 100644 --- a/app/dashboard/templates/dashboard/pricing.html +++ b/app/dashboard/templates/dashboard/pricing.html @@ -57,13 +57,14 @@ - {% if current_user.is_cancel() %} - + {% set sub = current_user.get_subscription() %} + {% if sub and sub.cancelled %} + {% endif %}
diff --git a/app/models.py b/app/models.py index dc5f3575..05a850ab 100644 --- a/app/models.py +++ b/app/models.py @@ -262,16 +262,6 @@ class User(db.Model, ModelMixin, UserMixin): return True - def next_bill_date(self) -> str: - sub: Subscription = self.get_subscription() - if sub: - return sub.next_bill_date.strftime("%Y-%m-%d") - - LOG.error( - f"next_bill_date() should be called only on user with active subscription. User {self}" - ) - return "" - def is_cancel(self) -> bool: """User has canceled their subscription but the subscription is still active, i.e. next_bill_date > now""" diff --git a/cron.py b/cron.py index 912d69a7..651092c6 100644 --- a/cron.py +++ b/cron.py @@ -63,8 +63,16 @@ def notify_premium_end(): send_email( user.email, f"Your subscription will end soon {user.name}", - render("transactional/subscription-end.txt", user=user), - render("transactional/subscription-end.html", user=user), + render( + "transactional/subscription-end.txt", + user=user, + next_bill_date=sub.next_bill_date.strftime("%Y-%m-%d"), + ), + render( + "transactional/subscription-end.html", + user=user, + next_bill_date=sub.next_bill_date.strftime("%Y-%m-%d"), + ), ) diff --git a/templates/emails/transactional/subscription-end.html b/templates/emails/transactional/subscription-end.html index 6d4d5a2b..dcb4536f 100644 --- a/templates/emails/transactional/subscription-end.html +++ b/templates/emails/transactional/subscription-end.html @@ -7,7 +7,7 @@ {{ render_text("Hi,") }} {% endif %} - {{ render_text("Your subscription will end on " + user.next_bill_date() + ".") }} + {{ render_text("Your subscription will end on " + next_bill_date + ".") }} {{ render_text("When the subscription ends:") }} diff --git a/templates/emails/transactional/subscription-end.txt b/templates/emails/transactional/subscription-end.txt index 32c6b881..b84c90fd 100644 --- a/templates/emails/transactional/subscription-end.txt +++ b/templates/emails/transactional/subscription-end.txt @@ -1,6 +1,6 @@ Hi {{user.name}} -Your subscription will end on {{ user.next_bill_date() }}. +Your subscription will end on {{ next_bill_date }}. When the subscription ends: diff --git a/templates/header.html b/templates/header.html index 7161fe40..973c8860 100644 --- a/templates/header.html +++ b/templates/header.html @@ -28,9 +28,11 @@ {% if current_user.in_trial() %} Trial ends {{ current_user.trial_end|dt }} {% elif current_user.is_premium() %} + Premium - {% if current_user.is_cancel() %} - until {{ current_user.next_bill_date() }} + {% set sub = current_user.get_subscription() %} + {% if sub and sub.cancelled %} + until {{ sub.next_bill_date.strftime("%Y-%m-%d") }} {% endif %} {% endif %} From 2a837f9213a7c897cb9507fa1f8db33fac17d39e Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 19 Apr 2020 10:54:15 +0200 Subject: [PATCH 04/10] remove user.is_cancel() --- app/models.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/app/models.py b/app/models.py index 05a850ab..2018bd93 100644 --- a/app/models.py +++ b/app/models.py @@ -262,15 +262,6 @@ class User(db.Model, ModelMixin, UserMixin): return True - def is_cancel(self) -> bool: - """User has canceled their subscription but the subscription is still active, - i.e. next_bill_date > now""" - sub: Subscription = self.get_subscription() - if sub and sub.cancelled: - return True - - return False - def is_premium(self) -> bool: """ user is premium if they: From 85fd4412ba36e3cdcf5ae0b53041f38b1434e615 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 19 Apr 2020 10:58:32 +0200 Subject: [PATCH 05/10] take into account AppleSubscription in premium formula --- app/models.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/models.py b/app/models.py index 2018bd93..f821dbd4 100644 --- a/app/models.py +++ b/app/models.py @@ -217,6 +217,10 @@ class User(db.Model, ModelMixin, UserMixin): 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.end_at > arrow.now(): return True @@ -251,6 +255,10 @@ class User(db.Model, ModelMixin, UserMixin): if sub and not sub.cancelled: return False + apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=self.id) + if apple_sub and apple_sub.is_valid(): + return False + manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id) # user who has giveaway premium can decide to upgrade if ( @@ -939,6 +947,10 @@ class AppleSubscription(db.Model, ModelMixin): user = db.relationship(User) + def is_valid(self): + # Todo: take into account grace period? + return self.expires_date > arrow.now() + class DeletedAlias(db.Model, ModelMixin): """Store all deleted alias to make sure they are NOT reused""" From 1bba38edb618396fef69f9f995108ba374783e8c Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 19 Apr 2020 11:13:38 +0200 Subject: [PATCH 06/10] Add POST /apple/process_payment --- README.md | 8 ++ app/api/__init__.py | 1 + app/api/views/apple.py | 286 ++++++++++++++++++++++++++++++++++++++++ app/config.py | 2 +- tests/api/test_apple.py | 27 ++++ 5 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 app/api/views/apple.py create mode 100644 tests/api/test_apple.py diff --git a/README.md b/README.md index 4aac854b..98a62f17 100644 --- a/README.md +++ b/README.md @@ -1097,6 +1097,14 @@ If success, 200. } ``` +#### POST /apple/process_payment + +Process payment receipt + +Input: +- `Authentication` header that contains the api key +- `receipt_data` the receipt_data base64Encoded returned by StoreKit, i.e. `rawReceiptData.base64EncodedString` + ### Database migration The database migration is handled by `alembic` diff --git a/app/api/__init__.py b/app/api/__init__.py index 182ac59e..a272e69c 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -6,4 +6,5 @@ from .views import ( auth, auth_mfa, alias, + apple, ) diff --git a/app/api/views/apple.py b/app/api/views/apple.py new file mode 100644 index 00000000..ac36c024 --- /dev/null +++ b/app/api/views/apple.py @@ -0,0 +1,286 @@ +from typing import Optional + +import arrow +from flask import g +from flask import jsonify +from flask import request +from flask_cors import cross_origin +import requests + +from app.api.base import api_bp, verify_api_key +from app.api.serializer import ( + AliasInfo, + serialize_alias_info, + get_alias_infos_with_pagination, +) +from app.config import APPLE_API_SECRET +from app.log import LOG +from app.models import PlanEnum, AppleSubscription + +_MONTHLY_PRODUCT_ID = "io.simplelogin.ios_app.subscription.premium.monthly" +_YEARLY_PRODUCT_ID = "io.simplelogin.ios_app.subscription.premium.yearly" + +# Apple API URL +_SANDBOX_URL = "https://sandbox.itunes.apple.com/verifyReceipt" +_PROD_URL = "https://buy.itunes.apple.com/verifyReceipt" + + +@api_bp.route("/apple/process_payment", methods=["POST"]) +@cross_origin() +@verify_api_key +def apple_process_payment(): + """ + Process payment + Input: + receipt_data: in body + Output: + 200 of the payment is successful, i.e. user is upgraded to premium + + """ + user = g.user + receipt_data = request.get_json().get("receipt_data") + + apple_sub = verify_receipt(receipt_data, user) + if apple_sub: + return jsonify(ok=True), 200 + + return jsonify(ok=False), 400 + + +def verify_receipt(receipt_data, user) -> 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 + """ + r = requests.post( + _PROD_URL, json={"receipt-data": receipt_data, "password": APPLE_API_SECRET} + ) + + 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": APPLE_API_SECRET}, + ) + + data = r.json() + LOG.d("response from Apple %s", data) + # 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.error( + "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"] + 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( + "Create new 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: + 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, + ) + + return apple_sub diff --git a/app/config.py b/app/config.py index 7b7c31e1..b0fbad06 100644 --- a/app/config.py +++ b/app/config.py @@ -245,4 +245,4 @@ with open(get_abs_path(DISPOSABLE_FILE_PATH), "r") as f: ] # Used when querying info on Apple API -APPLE_API_SECRET = os.environ.get("APPLE_API_SECRET") \ No newline at end of file +APPLE_API_SECRET = os.environ.get("APPLE_API_SECRET") diff --git a/tests/api/test_apple.py b/tests/api/test_apple.py new file mode 100644 index 00000000..8bb17477 --- /dev/null +++ b/tests/api/test_apple.py @@ -0,0 +1,27 @@ +from flask import url_for + +from app.extensions import db +from app.models import User, ApiKey + + +def test_apple_process_payment(flask_client): + user = User.create( + email="a@b.c", password="password", name="Test User", activated=True + ) + db.session.commit() + + # create api_key + api_key = ApiKey.create(user.id, "for test") + db.session.commit() + + receipt_data = """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""" + + r = flask_client.post( + url_for("api.apple_process_payment"), + headers={"Authentication": api_key.code}, + json={"receipt_data": receipt_data}, + ) + + # will fail anyway as there's apple secret is not valid + assert r.status_code == 400 + assert r.json == {"ok": False} From 71d53d16dacb2c15d7cd6fa9792796e7dfa8062a Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 19 Apr 2020 11:18:27 +0200 Subject: [PATCH 07/10] add poll_apple_subscription(), call it everyday --- cron.py | 16 ++++++++++++++++ crontab.yml | 6 ++++++ 2 files changed, 22 insertions(+) diff --git a/cron.py b/cron.py index 651092c6..c63d5c57 100644 --- a/cron.py +++ b/cron.py @@ -3,6 +3,7 @@ import argparse import arrow from app import s3 +from app.api.views.apple import verify_receipt from app.config import IGNORED_EMAILS, ADMIN_EMAIL from app.email_utils import send_email, send_trial_end_soon_email, render from app.extensions import db @@ -17,6 +18,7 @@ from app.models import ( Client, ManualSubscription, RefusedEmail, + AppleSubscription, ) from server import create_app @@ -105,6 +107,16 @@ def notify_manual_sub_end(): ) +def poll_apple_subscription(): + """Poll Apple API to update AppleSubscription""" + # todo: only near the end of the subscription + for apple_sub in AppleSubscription.query.all(): + user = apple_sub.user + verify_receipt(apple_sub.receipt_data, user) + + LOG.d("Finish poll_apple_subscription") + + def stats(): """send admin stats everyday""" if not ADMIN_EMAIL: @@ -206,6 +218,7 @@ if __name__ == "__main__": "notify_manual_subscription_end", "notify_premium_end", "delete_refused_emails", + "poll_apple_subscription" ], ) args = parser.parse_args() @@ -228,3 +241,6 @@ if __name__ == "__main__": elif args.job == "delete_refused_emails": LOG.d("Deleted refused emails") delete_refused_emails() + elif args.job == "poll_apple_subscription": + LOG.d("Poll Apple Subscriptions") + poll_apple_subscription() diff --git a/crontab.yml b/crontab.yml index 0c453757..fc61a44f 100644 --- a/crontab.yml +++ b/crontab.yml @@ -28,3 +28,9 @@ jobs: shell: /bin/bash schedule: "0 11 * * *" captureStderr: true + + - name: SimpleLogin Poll Apple Subscriptions + command: python /code/cron.py -j poll_apple_subscription + shell: /bin/bash + schedule: "0 12 * * *" + captureStderr: true From 11772d35e1ac59306a1b3ca01dcd2bb235a04d85 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 19 Apr 2020 11:18:42 +0200 Subject: [PATCH 08/10] Add sql migration --- .../versions/2020_041911_dd911f880b75_.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 migrations/versions/2020_041911_dd911f880b75_.py diff --git a/migrations/versions/2020_041911_dd911f880b75_.py b/migrations/versions/2020_041911_dd911f880b75_.py new file mode 100644 index 00000000..674b71e6 --- /dev/null +++ b/migrations/versions/2020_041911_dd911f880b75_.py @@ -0,0 +1,47 @@ +"""empty message + +Revision ID: dd911f880b75 +Revises: 57ef03f3ac34 +Create Date: 2020-04-19 11:14:19.929910 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'dd911f880b75' +down_revision = '57ef03f3ac34' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('apple_subscription', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False), + sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('expires_date', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False), + sa.Column('original_transaction_id', sa.String(length=256), nullable=False), + sa.Column('receipt_data', sa.Text(), nullable=False), + sa.Column('plan', sa.Enum('monthly', 'yearly', name='planenum'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id') + ) + op.alter_column('file', 'user_id', + existing_type=sa.INTEGER(), + nullable=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('file', 'user_id', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_table('apple_subscription') + # ### end Alembic commands ### From b33ec7d025b3065238a1e093096b83221a73035c Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 19 Apr 2020 11:20:44 +0200 Subject: [PATCH 09/10] fix reformatting --- README.md | 4 ++-- cron.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 98a62f17..8f160437 100644 --- a/README.md +++ b/README.md @@ -1102,8 +1102,8 @@ If success, 200. Process payment receipt Input: -- `Authentication` header that contains the api key -- `receipt_data` the receipt_data base64Encoded returned by StoreKit, i.e. `rawReceiptData.base64EncodedString` +- `Authentication` in header: the api key +- `receipt_data` in body: the receipt_data base64Encoded returned by StoreKit, i.e. `rawReceiptData.base64EncodedString` ### Database migration diff --git a/cron.py b/cron.py index c63d5c57..e1715d07 100644 --- a/cron.py +++ b/cron.py @@ -218,7 +218,7 @@ if __name__ == "__main__": "notify_manual_subscription_end", "notify_premium_end", "delete_refused_emails", - "poll_apple_subscription" + "poll_apple_subscription", ], ) args = parser.parse_args() From bf55ba052178b6bb9ce03a838b70f0518584e4ba Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 19 Apr 2020 11:22:05 +0200 Subject: [PATCH 10/10] Add output doc for POST /apple/process_payment --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 8f160437..9aa637ea 100644 --- a/README.md +++ b/README.md @@ -1105,6 +1105,10 @@ Input: - `Authentication` in header: the api key - `receipt_data` in body: the receipt_data base64Encoded returned by StoreKit, i.e. `rawReceiptData.base64EncodedString` +Output: +200 if user is upgraded successfully +4** if any error. + ### Database migration The database migration is handled by `alembic`