diff --git a/app/api/views/apple.py b/app/api/views/apple.py index 119bd29b..8b5125bb 100644 --- a/app/api/views/apple.py +++ b/app/api/views/apple.py @@ -9,6 +9,7 @@ from requests import RequestException from app.api.base import api_bp, require_api_auth from app.config import APPLE_API_SECRET, MACAPP_APPLE_API_SECRET +from app.subscription_webhook import execute_subscription_webhook from app.db import Session from app.log import LOG from app.models import PlanEnum, AppleSubscription @@ -50,6 +51,7 @@ def apple_process_payment(): apple_sub = verify_receipt(receipt_data, user, password) if apple_sub: + execute_subscription_webhook(user) return jsonify(ok=True), 200 return jsonify(error="Processing failed"), 400 @@ -282,6 +284,7 @@ def apple_update_notification(): apple_sub.plan = plan apple_sub.product_id = transaction["product_id"] Session.commit() + execute_subscription_webhook(user) return jsonify(ok=True), 200 else: LOG.w( @@ -554,6 +557,7 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]: product_id=latest_transaction["product_id"], ) + execute_subscription_webhook(user) Session.commit() return apple_sub diff --git a/app/config.py b/app/config.py index 782f693d..3818c7d1 100644 --- a/app/config.py +++ b/app/config.py @@ -532,3 +532,5 @@ if ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT: SKIP_MX_LOOKUP_ON_CHECK = False DISABLE_RATE_LIMIT = "DISABLE_RATE_LIMIT" in os.environ + +SUBSCRIPTION_CHANGE_WEBHOOK = os.environ.get("SUBCRIPTION_CHANGE_WEBHOOK", None) diff --git a/app/models.py b/app/models.py index 7a1ad0a8..4b4d6256 100644 --- a/app/models.py +++ b/app/models.py @@ -673,6 +673,22 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle): return None + def get_active_subscription_end( + self, include_partner_subscription: bool = True + ) -> Optional[arrow.Arrow]: + sub = self.get_active_subscription( + include_partner_subscription=include_partner_subscription + ) + if isinstance(sub, Subscription): + return arrow.get(sub.next_bill_date) + if isinstance(sub, AppleSubscription): + return sub.expires_date + if isinstance(sub, ManualSubscription): + return sub.end_at + if isinstance(sub, CoinbaseSubscription): + return sub.end_at + return None + # region Billing def lifetime_or_active_subscription( self, include_partner_subscription: bool = True diff --git a/app/subscription_webhook.py b/app/subscription_webhook.py new file mode 100644 index 00000000..57952520 --- /dev/null +++ b/app/subscription_webhook.py @@ -0,0 +1,33 @@ +import requests +from requests import RequestException + +from app import config +from app.log import LOG +from app.models import User + + +def execute_subscription_webhook(user: User): + webhook_url = config.SUBSCRIPTION_CHANGE_WEBHOOK + if webhook_url is None: + return + subscription_end = user.get_active_subscription_end( + include_partner_subscription=False + ) + sl_subscription_end = None + if subscription_end: + sl_subscription_end = subscription_end.timestamp + payload = { + "user_id": user.id, + "is_premium": user.is_premium(), + "active_subscription_end": sl_subscription_end, + } + try: + response = requests.post(webhook_url, json=payload) + if response.status_code == 200: + LOG.i("Sent request to subscription update webhook successfully") + else: + LOG.i( + f"Request to webhook failed with statue {response.status_code}: {response.text}" + ) + except RequestException as e: + LOG.warn(f"Subscription request exception: {e}") diff --git a/server.py b/server.py index c31fb5d6..8ad833f0 100644 --- a/server.py +++ b/server.py @@ -79,6 +79,7 @@ from app.config import ( MEM_STORE_URI, ) from app.dashboard.base import dashboard_bp +from app.subscription_webhook import execute_subscription_webhook from app.db import Session from app.developer.base import developer_bp from app.discover.base import discover_bp @@ -491,6 +492,7 @@ def setup_paddle_callback(app: Flask): # in case user cancels a plan and subscribes a new plan sub.cancelled = False + execute_subscription_webhook(user) LOG.d("User %s upgrades!", user) Session.commit() @@ -509,6 +511,7 @@ def setup_paddle_callback(app: Flask): ).date() Session.commit() + execute_subscription_webhook(sub.user) elif request.form.get("alert_name") == "subscription_cancelled": subscription_id = request.form.get("subscription_id") @@ -538,6 +541,7 @@ def setup_paddle_callback(app: Flask): end_date=request.form.get("cancellation_effective_date"), ), ) + execute_subscription_webhook(sub.user) else: # user might have deleted their account @@ -580,6 +584,7 @@ def setup_paddle_callback(app: Flask): sub.cancelled = False Session.commit() + execute_subscription_webhook(sub.user) else: LOG.w( f"update non-exist subscription {subscription_id}. {request.form}" @@ -596,6 +601,7 @@ def setup_paddle_callback(app: Flask): Subscription.delete(sub.id) Session.commit() LOG.e("%s requests a refund", user) + execute_subscription_webhook(sub.user) elif request.form.get("alert_name") == "subscription_payment_refunded": subscription_id = request.form.get("subscription_id") @@ -629,6 +635,7 @@ def setup_paddle_callback(app: Flask): LOG.e("Unknown plan_id %s", plan_id) else: LOG.w("partial subscription_payment_refunded, not handled") + execute_subscription_webhook(sub.user) return "OK" @@ -742,6 +749,7 @@ def handle_coinbase_event(event) -> bool: coinbase_subscription=coinbase_subscription, ), ) + execute_subscription_webhook(user) return True diff --git a/tests/test_subscription_webhook.py b/tests/test_subscription_webhook.py new file mode 100644 index 00000000..6b09200c --- /dev/null +++ b/tests/test_subscription_webhook.py @@ -0,0 +1,113 @@ +import http.server +import json +import threading + +import arrow + +from app import config +from app.models import ( + Subscription, + AppleSubscription, + CoinbaseSubscription, + ManualSubscription, +) +from tests.utils import create_new_user + +from app.subscription_webhook import execute_subscription_webhook + +http_server = None +last_http_request = None + + +def setup_module(): + global http_server + http_server = http.server.ThreadingHTTPServer(("", 0), HTTPTestServer) + print(http_server.server_port) + threading.Thread(target=http_server.serve_forever, daemon=True).start() + config.SUBSCRIPTION_CHANGE_WEBHOOK = f"http://localhost:{http_server.server_port}" + + +def teardown_module(): + global http_server + config.SUBSCRIPTION_CHANGE_WEBHOOK = None + http_server.shutdown() + + +class HTTPTestServer(http.server.BaseHTTPRequestHandler): + def do_POST(self): + global last_http_request + content_len = int(self.headers.get("Content-Length")) + body_data = self.rfile.read(content_len) + last_http_request = json.loads(body_data) + self.send_response(200) + + +def test_webhook_with_trial(): + user = create_new_user() + execute_subscription_webhook(user) + assert last_http_request["user_id"] == user.id + assert last_http_request["is_premium"] + assert last_http_request["active_subscription_end"] is None + + +def test_webhook_with_subscription(): + user = create_new_user() + end_at = arrow.utcnow().shift(days=1).replace(hour=0, minute=0, second=0) + Subscription.create( + user_id=user.id, + cancel_url="", + update_url="", + subscription_id="", + event_time=arrow.now(), + next_bill_date=end_at.date(), + plan="yearly", + flush=True, + ) + execute_subscription_webhook(user) + assert last_http_request["user_id"] == user.id + assert last_http_request["is_premium"] + assert last_http_request["active_subscription_end"] == end_at.timestamp + + +def test_webhook_with_apple_subscription(): + user = create_new_user() + end_at = arrow.utcnow().shift(days=2).replace(hour=0, minute=0, second=0) + AppleSubscription.create( + user_id=user.id, + receipt_data=arrow.now().date().strftime("%Y-%m-%d"), + expires_date=end_at.date().strftime("%Y-%m-%d"), + original_transaction_id="", + plan="yearly", + product_id="", + flush=True, + ) + execute_subscription_webhook(user) + assert last_http_request["user_id"] == user.id + assert last_http_request["is_premium"] + assert last_http_request["active_subscription_end"] == end_at.timestamp + + +def test_webhook_with_coinbase_subscription(): + user = create_new_user() + end_at = arrow.utcnow().shift(days=3).replace(hour=0, minute=0, second=0) + CoinbaseSubscription.create( + user_id=user.id, end_at=end_at.date().strftime("%Y-%m-%d"), flush=True + ) + + execute_subscription_webhook(user) + assert last_http_request["user_id"] == user.id + assert last_http_request["is_premium"] + assert last_http_request["active_subscription_end"] == end_at.timestamp + + +def test_webhook_with_manual_subscription(): + user = create_new_user() + end_at = arrow.utcnow().shift(days=3).replace(hour=0, minute=0, second=0) + ManualSubscription.create( + user_id=user.id, end_at=end_at.date().strftime("%Y-%m-%d"), flush=True + ) + + execute_subscription_webhook(user) + assert last_http_request["user_id"] == user.id + assert last_http_request["is_premium"] + assert last_http_request["active_subscription_end"] == end_at.timestamp