Have subscription callback whenever a subscription changes (#1748)
* Have subscription callback whenever a subscription changes * Fixed tests --------- Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
This commit is contained in:
parent
3de83f2f05
commit
e43a2dd34d
|
@ -9,6 +9,7 @@ from requests import RequestException
|
||||||
|
|
||||||
from app.api.base import api_bp, require_api_auth
|
from app.api.base import api_bp, require_api_auth
|
||||||
from app.config import APPLE_API_SECRET, MACAPP_APPLE_API_SECRET
|
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.db import Session
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import PlanEnum, AppleSubscription
|
from app.models import PlanEnum, AppleSubscription
|
||||||
|
@ -50,6 +51,7 @@ def apple_process_payment():
|
||||||
|
|
||||||
apple_sub = verify_receipt(receipt_data, user, password)
|
apple_sub = verify_receipt(receipt_data, user, password)
|
||||||
if apple_sub:
|
if apple_sub:
|
||||||
|
execute_subscription_webhook(user)
|
||||||
return jsonify(ok=True), 200
|
return jsonify(ok=True), 200
|
||||||
|
|
||||||
return jsonify(error="Processing failed"), 400
|
return jsonify(error="Processing failed"), 400
|
||||||
|
@ -282,6 +284,7 @@ def apple_update_notification():
|
||||||
apple_sub.plan = plan
|
apple_sub.plan = plan
|
||||||
apple_sub.product_id = transaction["product_id"]
|
apple_sub.product_id = transaction["product_id"]
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
execute_subscription_webhook(user)
|
||||||
return jsonify(ok=True), 200
|
return jsonify(ok=True), 200
|
||||||
else:
|
else:
|
||||||
LOG.w(
|
LOG.w(
|
||||||
|
@ -554,6 +557,7 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
|
||||||
product_id=latest_transaction["product_id"],
|
product_id=latest_transaction["product_id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
execute_subscription_webhook(user)
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
|
||||||
return apple_sub
|
return apple_sub
|
||||||
|
|
|
@ -532,3 +532,5 @@ if ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT:
|
||||||
SKIP_MX_LOOKUP_ON_CHECK = False
|
SKIP_MX_LOOKUP_ON_CHECK = False
|
||||||
|
|
||||||
DISABLE_RATE_LIMIT = "DISABLE_RATE_LIMIT" in os.environ
|
DISABLE_RATE_LIMIT = "DISABLE_RATE_LIMIT" in os.environ
|
||||||
|
|
||||||
|
SUBSCRIPTION_CHANGE_WEBHOOK = os.environ.get("SUBCRIPTION_CHANGE_WEBHOOK", None)
|
||||||
|
|
|
@ -673,6 +673,22 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||||
|
|
||||||
return None
|
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
|
# region Billing
|
||||||
def lifetime_or_active_subscription(
|
def lifetime_or_active_subscription(
|
||||||
self, include_partner_subscription: bool = True
|
self, include_partner_subscription: bool = True
|
||||||
|
|
|
@ -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}")
|
|
@ -79,6 +79,7 @@ from app.config import (
|
||||||
MEM_STORE_URI,
|
MEM_STORE_URI,
|
||||||
)
|
)
|
||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
|
from app.subscription_webhook import execute_subscription_webhook
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
from app.developer.base import developer_bp
|
from app.developer.base import developer_bp
|
||||||
from app.discover.base import discover_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
|
# in case user cancels a plan and subscribes a new plan
|
||||||
sub.cancelled = False
|
sub.cancelled = False
|
||||||
|
|
||||||
|
execute_subscription_webhook(user)
|
||||||
LOG.d("User %s upgrades!", user)
|
LOG.d("User %s upgrades!", user)
|
||||||
|
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
@ -509,6 +511,7 @@ def setup_paddle_callback(app: Flask):
|
||||||
).date()
|
).date()
|
||||||
|
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
execute_subscription_webhook(sub.user)
|
||||||
|
|
||||||
elif request.form.get("alert_name") == "subscription_cancelled":
|
elif request.form.get("alert_name") == "subscription_cancelled":
|
||||||
subscription_id = request.form.get("subscription_id")
|
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"),
|
end_date=request.form.get("cancellation_effective_date"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
execute_subscription_webhook(sub.user)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# user might have deleted their account
|
# user might have deleted their account
|
||||||
|
@ -580,6 +584,7 @@ def setup_paddle_callback(app: Flask):
|
||||||
sub.cancelled = False
|
sub.cancelled = False
|
||||||
|
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
execute_subscription_webhook(sub.user)
|
||||||
else:
|
else:
|
||||||
LOG.w(
|
LOG.w(
|
||||||
f"update non-exist subscription {subscription_id}. {request.form}"
|
f"update non-exist subscription {subscription_id}. {request.form}"
|
||||||
|
@ -596,6 +601,7 @@ def setup_paddle_callback(app: Flask):
|
||||||
Subscription.delete(sub.id)
|
Subscription.delete(sub.id)
|
||||||
Session.commit()
|
Session.commit()
|
||||||
LOG.e("%s requests a refund", user)
|
LOG.e("%s requests a refund", user)
|
||||||
|
execute_subscription_webhook(sub.user)
|
||||||
|
|
||||||
elif request.form.get("alert_name") == "subscription_payment_refunded":
|
elif request.form.get("alert_name") == "subscription_payment_refunded":
|
||||||
subscription_id = request.form.get("subscription_id")
|
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)
|
LOG.e("Unknown plan_id %s", plan_id)
|
||||||
else:
|
else:
|
||||||
LOG.w("partial subscription_payment_refunded, not handled")
|
LOG.w("partial subscription_payment_refunded, not handled")
|
||||||
|
execute_subscription_webhook(sub.user)
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
@ -742,6 +749,7 @@ def handle_coinbase_event(event) -> bool:
|
||||||
coinbase_subscription=coinbase_subscription,
|
coinbase_subscription=coinbase_subscription,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
execute_subscription_webhook(user)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
|
@ -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
|
Loading…
Reference in New Issue