From bea8e25389bf37ccc9d1abc51456eb382449ff41 Mon Sep 17 00:00:00 2001 From: KellyRoussel Date: Fri, 26 Jul 2024 12:35:32 +0200 Subject: [PATCH] remove apple in app purchase route --- backend/app/http_routes.py | 2 - backend/app/routes/inapp_apple_purchase.py | 343 ------------------ .../products/how_it_works.md | 14 - docs/openAPI/backend_api.yaml | 74 ---- 4 files changed, 433 deletions(-) delete mode 100644 backend/app/routes/inapp_apple_purchase.py diff --git a/backend/app/http_routes.py b/backend/app/http_routes.py index 6afb78ac..90e87e9d 100644 --- a/backend/app/http_routes.py +++ b/backend/app/http_routes.py @@ -27,7 +27,6 @@ from routes.product import Product from routes.product_task_association import ProductTaskAssociation from routes.manual_purchase import ManualPurchase -from routes.inapp_apple_purchase import InAppApplePurchase from routes.todos import Todos from routes.vocabulary import Vocabulary from routes.product_category import ProductCategory @@ -79,7 +78,6 @@ def __init__(self, api): api.add_resource(Product, "/product") api.add_resource(ProductTaskAssociation, "/product_task_association") api.add_resource(ManualPurchase, "/manual_purchase") - api.add_resource(InAppApplePurchase, "/in_app_apple_purchase") api.add_resource(Todos, "/todos") api.add_resource(Vocabulary, "/vocabulary") api.add_resource(ProductCategory, "/product_category") diff --git a/backend/app/routes/inapp_apple_purchase.py b/backend/app/routes/inapp_apple_purchase.py deleted file mode 100644 index 1fa1b8e9..00000000 --- a/backend/app/routes/inapp_apple_purchase.py +++ /dev/null @@ -1,343 +0,0 @@ -import base64 -import json -import os - -from appstoreserverlibrary.api_client import AppStoreServerAPIClient -from flask import request -from flask_restful import Resource - -from app import db -from mojodex_core.authentication import authenticate -from mojodex_core.entities.db_base_entities import * -from models.purchase_manager import PurchaseManager -from mojodex_backend_logger import MojodexBackendLogger -from appstoreserverlibrary.models.Environment import Environment - -from mojodex_core.email_sender.email_service import EmailService -from mojodex_core.logging_handler import log_error -from datetime import datetime -class InAppApplePurchase(Resource): - logger_prefix = "InAppApplePurchase Resource:: " - - def __init__(self): - InAppApplePurchase.method_decorators = [authenticate(["PUT"])] - self.logger = MojodexBackendLogger(f"{InAppApplePurchase.logger_prefix}") - - - def _decode_apple_jws(self, jws_to_decode): - try: - # Parse signedPayload to identify the JWS header, payload, and signature representations. - # Split the JWT token into header, payload, and signature - header_base64url, payload_base64url, signature_base64url = jws_to_decode.split('.') - # Decode the header from base64 - decoded_payload = base64.urlsafe_b64decode(payload_base64url + "===").decode("utf-8") - # to json - decoded_jws_payload = json.loads(decoded_payload) - return decoded_jws_payload - except Exception as e: - raise Exception(f"decode_apple_jws : {e}") - - def __extract_info_from_decoded_transaction(self, decoded_transaction): - try: - # signed_transaction_info keys: ['transactionId', 'originalTransactionId', 'webOrderLineItemId', 'bundleId', - # 'productId', 'subscriptionGroupIdentifier', 'purchaseDate', 'originalPurchaseDate', 'expiresDate', - # 'quantity', 'type', 'inAppOwnershipType', 'signedDate', 'environment', 'transactionReason', 'storefront', - # 'storefrontId', 'price', 'currency'] - transactionId = decoded_transaction[ - "transactionId"] if "transactionId" in decoded_transaction else None - originalTransactionId = decoded_transaction[ - "originalTransactionId"] if "originalTransactionId" in decoded_transaction else None - transactionReason = decoded_transaction[ - "transactionReason"] if "transactionReason" in decoded_transaction else None - productId = decoded_transaction["productId"] if "productId" in decoded_transaction else None - return transactionId, originalTransactionId, transactionReason, productId - except Exception as e: - raise Exception(f"__extract_info_from_decoded_transaction : {e}") - - - # Apple callback - def post(self): - try: - if not request.is_json: - log_error(f"Error adding purchase : Request must be JSON", notify_admin=True) - return {"error": "Request must be JSON"}, 400 - except Exception as e: - log_error(f"Error on apple purchase webhook : Request must be JSON", notify_admin=True) - return {"error": "Request must be JSON"}, 400 - - try: - signed_payload = request.json["signedPayload"] - except Exception as e: - log_error(f"Error on apple purchase webhook : Missing field signedPayload - request.json: {request.json}", notify_admin=True) - return {"error": f"Missing field signedPayload"}, 400 - - notificationType, notificationSubType = None, None - transactionId, originalTransactionId = None, None - purchase, user = None, None - try: - decoded_jws = self._decode_apple_jws(signed_payload) - # decoded_jws keys: ['notificationType', 'subtype', 'notificationUUID', 'data', 'version', 'signedDate'] - notificationType = decoded_jws["notificationType"] # https://developer.apple.com/documentation/appstoreservernotifications/notificationtype - self.logger.debug(f"notificationType: {notificationType}") - notificationSubType = decoded_jws["subtype"] if "subtype" in decoded_jws else None - self.logger.debug(f"notificationSubType: {notificationSubType}") - data = decoded_jws["data"] - # data keys: ['appAppleId', 'bundleId', 'bundleVersion', 'environment', 'signedTransactionInfo', 'signedRenewalInfo', 'status'] - signed_transaction_info_jws = data["signedTransactionInfo"] - signed_transaction_info = self._decode_apple_jws(signed_transaction_info_jws) - transactionId, originalTransactionId, transactionReason, productId = self.__extract_info_from_decoded_transaction(signed_transaction_info) - if transactionId is None: - log_error( - f"Received apple purchase webhook with no transactionId") - EmailService().send("🚨 URGENT: Apple purchase callback with no transactionId", - PurchaseManager.purchases_email_receivers, - f"signed_transaction_info : {signed_transaction_info}" - f"\nEvent: NOTIFICATION_TYPE: {notificationType} - Subtype: {notificationSubType} - originalTransactionId: {originalTransactionId}") - return { - "error": f"Apple purchase callback with no transactionId"}, 400 - - - self.logger.debug(f"transactionId: {transactionId}") - self.logger.debug(f"originalTransactionId: {originalTransactionId}") - self.logger.debug(f"transactionReason: {transactionReason}") - self.logger.debug(f"productId: {productId}") - - purchase_manager = PurchaseManager() - - # Manage notificationType - if (notificationType == "SUBSCRIBED" and notificationSubType in ["INITIAL_BUY", "RESUBSCRIBE"]) or notificationType == "DID_RENEW": - # Create purchase - # check purchase with this transactionId does not already exist => If yes, do nothing - purchase = db.session.query(MdPurchase).filter(MdPurchase.apple_transaction_id == transactionId).first() - if purchase is not None: - return {}, 200 - self.logger.debug(f'Create purchase with transactionId: {transactionId} - originalTransactionId: {originalTransactionId}') - product = db.session.query(MdProduct).filter(MdProduct.product_apple_id == productId).first() - purchase = MdPurchase( - product_fk=product.product_pk, - creation_date=datetime.now(), - apple_transaction_id=transactionId, - apple_original_transaction_id=originalTransactionId, - active=False - ) - db.session.add(purchase) - db.session.flush() - if notificationSubType == "BILLING_RECOVERY": - # The expired subscription that previously failed to renew has successfully renewed - # If last purchase is not active, activate it again - self.logger.debug('The expired subscription that previously failed to renew has successfully renewed') - EmailService().send( "Subscription renewed after billing recovery", - PurchaseManager.purchases_email_receivers, - f"Subscription with originalTransactionId {originalTransactionId} and transactionId {transactionId} that previously failed to renew has successfully renewed." - f"It has been re-activated") - elif notificationType == "DID_RENEW": - # just automatic renew - self.logger.debug('Automatic renew') - else: - # Be sure we have a purchase and a user associated to the originalTransactionId - purchase = db.session.query(MdPurchase).filter( - MdPurchase.apple_transaction_id == originalTransactionId).first() - if purchase is None: - log_error( - f"Purchase with transactionId {originalTransactionId} does not exist in mojodex db") - EmailService().send("🚨 URGENT: Subscription error", - PurchaseManager.purchases_email_receivers, - f"Apple originalTransactionId {originalTransactionId} subscription event but associated purchase was not found in db." - f"\nEvent: NOTIFICATION_TYPE: {notificationType} - Subtype: {notificationSubType} - TransactionID: {transactionId}") - return { - "error": f"Purchase with apple_transaction_id {originalTransactionId} does not exist in mojodex db"}, 400 - - if purchase.user_id is None: - log_error(f"Purchase with apple_transaction_id {originalTransactionId} has no user associated") - EmailService().send("🚨 URGENT: Subscription error", - PurchaseManager.purchases_email_receivers, - f"Apple originalTransactionId {originalTransactionId} - purchase_pk: {purchase.purchase_pk} - subscription event but associated purchase has no user associated." - f"\nEvent: NOTIFICATION_TYPE: {notificationType} - Subtype: {notificationSubType} - TransactionID: {transactionId}") - return { - "error": f"Purchase with originalTransactionId {originalTransactionId} has no user associated"}, 400 - - user = db.session.query(MdUser).filter(MdUser.user_id == purchase.user_id).first() - if user is None: - log_error(f"Purchase with apple_transaction_id {originalTransactionId} has unknown user associated") - EmailService().send("🚨 URGENT: Subscription error", - PurchaseManager.purchases_email_receivers, - f"Apple originalTransactionId {originalTransactionId} - purchase_pk: {purchase.purchase_pk} - subscription event but associated purchase has unknown user associated." - f"\nEvent: NOTIFICATION_TYPE: {notificationType} - Subtype: {notificationSubType} - TransactionID: {transactionId}") - return { - "error": f"Purchase with apple_transaction_id {originalTransactionId} has unknown user associated"}, 400 - - - # else, it's just a renew, do nothing - if notificationType == "DID_FAIL_TO_RENEW": - if notificationSubType == "GRACE_PERIOD": - # The subscription enters the billing retry period - # We should inform the user that there may be an issue with their billing information. - # But let access - self.logger.debug('The subscription enters the billing retry period') - # Send email to admins - EmailService().send("Subscription entering billing retry period", - PurchaseManager.purchases_email_receivers, - f"Subscription of user {user.email} enters the billing retry period." - f"We should inform them that there may be an issue with their billing information.\n" - f"Access to purchase {purchase.purchase_pk} is still allowed.") - else: - # Stop access to service - self.logger.debug('Stop access to service') - purchase_manager.deactivate_purchase(purchase) - # Send email to admins - EmailService().send("Subscription ended", - PurchaseManager.purchases_email_receivers, - f"Subscription of user {user.email} ended") - - - elif notificationType == "EXPIRED": - # Stop access to service - self.logger.debug('Stop access to service') - # Send email to Admin with subtype to understand why - purchase_manager.deactivate_purchase(purchase) - # Send email to admins - EmailService().send( - "Subscription ended", - PurchaseManager.purchases_email_receivers, - f"Subscription of user {user.email} ended. notificationType=EXPIRED - Subtype: {notificationSubType}") - else: - # Many possible causes, just send email to admin for manual check - self.logger.debug('Many possible causes, just send email to Admin for manual check') - EmailService().send( - "🚨 URGENT: Something unexpected happened on a purchase", - PurchaseManager.purchases_email_receivers, - f"notificationType={notificationType} - Subtype: {notificationSubType} - TransactionID: {transactionId} " - f"- OriginalTransactionID: {originalTransactionId} - purchase_pk: {purchase.purchase_pk} - user_email: {user.email}") - - db.session.commit() - return {}, 200 - except Exception as e: - db.session.rollback() - log_error(f"Error on apple purchase webhook : {e}") - EmailService().send( - "🚨 URGENT: Error happened on a purchase", - PurchaseManager.purchases_email_receivers, - f"notificationType={notificationType} - Subtype: {notificationSubType} - TransactionID: {transactionId} " - f"- OriginalTransactionID: {originalTransactionId} - purchase_pk: {purchase.purchase_pk} - user_email: {user.email}") - return {"error": f"Error on apple purchase webhook : {e}"}, 400 - - - def get_transaction_from_id(self, transaction_id): - try: - key_id = os.environ.get("APPLE_PRIVATE_KEY_ID") - issuer_id = os.environ.get("APPLE_ISSUER_ID") - bundle_id = os.environ.get("APPLE_BUNDLE_ID") - environment = Environment.PRODUCTION if os.environ.get("APPLE_ENVIRONMENT")=="prod" else Environment.SANDBOX - - private_key = os.environ.get("APPLE_PRIVATE_KEY") - private_key = private_key.replace(" ", "\n") - private_key = "-----BEGIN PRIVATE KEY-----" + private_key + "-----END PRIVATE KEY-----" - # to bytes - private_key = private_key.encode() - - client = AppStoreServerAPIClient(private_key, key_id, issuer_id, bundle_id, environment) - try: - response = client.get_transaction_info(transaction_id) - except Exception as e: - raise Exception(f"get_transaction_from_id - client.get_transaction_info : {e}") - transaction_info = response.signedTransactionInfo - # decode - decoded_jws_payload = self._decode_apple_jws(transaction_info) - return decoded_jws_payload - except Exception as e: - raise Exception(f"get_transaction_from_id : {e}") - - - # Associate user_id to purchase = verify transaction - def put(self, user_id): - if not request.is_json: - log_error(f"Error adding apple purchase : Request must be JSON", notify_admin=True) - return {"error": "Request must be JSON"}, 400 - - try: - timestamp = request.json["datetime"] - transaction_id = request.json["transaction_id"] - except KeyError as e: - log_error(f"Error adding apple purchase : Missing field {e}", notify_admin=True) - return {"error": f"Missing field {e}"}, 400 - - try: - user = db.session.query(MdUser).filter(MdUser.user_id == user_id).first() - if not user: - log_error(f"Error adding purchase : Unknown user {user_id}") - EmailService().send("🚨 URGENT: New client purchase error", - PurchaseManager.purchases_email_receivers, - f"Trying to associate user to a purchase but user {user_id} not found in mojodex db") - return {"error": f"Unknown user {user_id}"}, 400 - - - result = db.session.query(MdPurchase, MdProduct)\ - .join(MdProduct, MdProduct.product_pk == MdPurchase.product_fk)\ - .filter(MdPurchase.apple_transaction_id == transaction_id).first() - - if not result: - signed_transaction_info = self.get_transaction_from_id(transaction_id) - transactionId, originalTransactionId, transactionReason, productId = self.__extract_info_from_decoded_transaction( - signed_transaction_info) - if transactionId is None: - log_error( - f"Error adding purchase : transaction with no transactionId") - EmailService().send("🚨 URGENT: New client purchase error", - PurchaseManager.purchases_email_receivers, - f"PUT /purchase signed_transaction_info : {signed_transaction_info}") - return { - "error": f"Error adding purchase : transaction with no transactionId"}, 400 - # if it is - # create purchase - product = db.session.query(MdProduct).filter(MdProduct.product_apple_id == productId).first() - purchase = MdPurchase( - product_fk=product.product_pk, - creation_date=datetime.now(), - apple_transaction_id=transactionId, - apple_original_transaction_id=originalTransactionId, - active=False - ) - db.session.add(purchase) - db.session.flush() - else: - purchase, product = result - - if purchase.user_id is not None: - if purchase.user_id != user_id: - log_error(f"Error adding purchase : Purchase with apple_transaction_id {transaction_id} already has a different user associated") - EmailService().send("🚨 URGENT: New client purchase error", - PurchaseManager.purchases_email_receivers, - f"Trying to associate user to a purchase but purchase with apple_transaction_id {transaction_id} already has a different user associated") - return {"error": f"Purchase with apple_transaction_id {transaction_id} already has a different user associated"}, 400 - else: - self.logger.debug(f"Purchase with apple_transaction_id {transaction_id} already has a user but same one") - return {}, 200 - - purchase_manager = PurchaseManager() - if not product.n_days_validity: # product is a subscription - if purchase_manager.user_has_active_subscription(user_id): - return {"error": f"User already has an active subscription"}, 400 - - purchase.user_id = user_id - - purchase.completed_date = datetime.now() - if purchase.apple_original_transaction_id == purchase.apple_transaction_id: # NEW PURCHASE - EmailService().send(subject="🥳 New client purchase", - recipients=PurchaseManager.purchases_email_receivers, - text=f"🎉 Congratulations ! {user.email} just bought {product.label} !") - # Activate purchase - purchase_manager.activate_purchase(purchase) - - db.session.commit() - return {}, 200 - except Exception as e: - db.session.rollback() - log_error(f"Error adding apple purchase - transactionId: {transaction_id}: {e}", notify_admin=True) - return {"error": f"Error adding purchase: {e}"}, 400 - - - - - - - diff --git a/docs/design-principles/products/how_it_works.md b/docs/design-principles/products/how_it_works.md index 7e220245..f53c444c 100644 --- a/docs/design-principles/products/how_it_works.md +++ b/docs/design-principles/products/how_it_works.md @@ -13,8 +13,6 @@ This allow users to keep a tailored experience matching their profile and needs. ### Buying a product using an implemented payment service -2 payment services have been implemented for now: - ### Stripe When the user wants to buy a product though Stripe, here is the flow: @@ -27,15 +25,3 @@ When the user wants to buy a product though Stripe, here is the flow: - associating and enabling the product's tasks to the user If the product bought is a subscription, it will be kept active and no Stripe webhook will be call as long as user's payments are up to date. If a payment fails, Stripe calls a webhook to handle the end of a purchase: POST `/subscription_end` (`backend/app/routes/purchase_end_stripe_webhook.py`). - -### Apple in-app purchase -When the user wants to buy a product though Apple in-app purchase, here is the flow: - -1. The user is redirected to the Apple in-app purchase flow -2. The user pays for the product and is redirected to Mojodex's success page. Apple calls webhook POST `/in_app_apple_purchase` (`backend/app/routes/inapp_apple_purchase.py`). This routes verifies the transaction and created an inactive purchase, not yet associated to a user. -3. The application calls route PUT `/in_app_apple_purchase` to confirm the purchase and associate it to the user (`backend/app/routes/inapp_apple_purchase.py`). This route activates the purchase by: -- deactivating any previous active subscription if bought product is a subscription -- associating and enabling the product's tasks to the user - -On the contrary of Stripe, regarding subscriptions, Apple does call the `/in_app_apple_purchase` webhook every month at payment renewal providing a new transaction ID. Old subscription purchase is deactived, a new purchase is created and associated to the user. -This `/in_app_apple_purchase` is also used to manage failed renewals, grace period and purchases expirations. diff --git a/docs/openAPI/backend_api.yaml b/docs/openAPI/backend_api.yaml index df217669..60ef4855 100644 --- a/docs/openAPI/backend_api.yaml +++ b/docs/openAPI/backend_api.yaml @@ -743,80 +743,6 @@ paths: properties: error: type: string - /in_app_apple_purchase: - post: - tags: - - External service - summary: Handle Apple in-app purchase notifications - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - signedPayload - properties: - signedPayload: - type: string - responses: - '200': - description: Notification processed successfully - '400': - description: Error processing notification - content: - application/json: - schema: - type: object - properties: - error: - type: string - put: - tags: - - Application - summary: Associate user_id to purchase and verify transaction - parameters: - - in: header - name: Authorization - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - datetime - - transaction_id - properties: - datetime: - type: string - format: date-time - transaction_id: - type: string - responses: - '200': - description: User associated with purchase successfully - '400': - description: Error associating user with purchase - content: - application/json: - schema: - type: object - properties: - error: - type: string - '403': - description: Authentication error wrong or missing secret - content: - application/json: - schema: - type: object - properties: - error: - type: string /is_email_service_configured: get: tags: