Merge pull request #132 from simple-login/apple

Apple Subscription
This commit is contained in:
Son Nguyen Kim 2020-04-19 11:22:28 +02:00 committed by GitHub
commit 18e5dffcd7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 458 additions and 34 deletions

View File

@ -1097,6 +1097,18 @@ If success, 200.
}
```
#### POST /apple/process_payment
Process payment receipt
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`

View File

@ -6,4 +6,5 @@ from .views import (
auth,
auth_mfa,
alias,
apple,
)

286
app/api/views/apple.py Normal file
View File

@ -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

View File

@ -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")

View File

@ -14,7 +14,7 @@
{% if sub.cancelled %}
<p>
You are on the <b>{{ sub.plan_name() }}</b> plan. <br>
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") }}
</p>
<hr>

View File

@ -57,13 +57,14 @@
</a>
</div>
{% if current_user.is_cancel() %}
<div class="alert alert-primary" role="alert">
You have an active subscription until {{current_user.next_bill_date()}}. <br>
Please note that if you re-subscribe now, this will be a completely
new subscription and
your payment method will be charged <b>immediately</b>.
</div>
{% set sub = current_user.get_subscription() %}
{% if sub and sub.cancelled %}
<div class="alert alert-primary" role="alert">
You have an active subscription until {{ sub.next_bill_date.strftime("%Y-%m-%d") }}. <br>
Please note that if you re-subscribe now, this will be a completely
new subscription and
your payment method will be charged <b>immediately</b>.
</div>
{% endif %}
<div class="mb-3">

View File

@ -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 (
@ -262,25 +270,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"""
sub: Subscription = self.get_subscription()
if sub and sub.cancelled:
return True
return False
def is_premium(self) -> bool:
"""
user is premium if they:
@ -940,6 +929,29 @@ 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)
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"""

28
cron.py
View File

@ -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
@ -63,8 +65,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"),
),
)
@ -97,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:
@ -198,6 +218,7 @@ if __name__ == "__main__":
"notify_manual_subscription_end",
"notify_premium_end",
"delete_refused_emails",
"poll_apple_subscription",
],
)
args = parser.parse_args()
@ -220,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()

View File

@ -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

View File

@ -122,4 +122,7 @@ FACEBOOK_CLIENT_SECRET=to_fill
# LOCAL_FILE_UPLOAD=true
# The landing page
# LANDING_PAGE_URL=https://simplelogin.io
# LANDING_PAGE_URL=https://simplelogin.io
# Used when querying info on Apple API
# APPLE_API_SECRET=secret

View File

@ -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 ###

View File

@ -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:") }}

View File

@ -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:

View File

@ -28,9 +28,11 @@
{% if current_user.in_trial() %}
<small class="text-success d-block mt-1">Trial ends {{ current_user.trial_end|dt }}</small>
{% elif current_user.is_premium() %}
<small class="text-success d-block mt-1">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 %}
</small>
{% endif %}

27
tests/api/test_apple.py Normal file

File diff suppressed because one or more lines are too long