{"record":{"id":"e3c410c29fc40418","repo":"signalapp/Signal-Server","slug":"must-cancel-subscription-with-storekit-before-dele","errorCode":null,"errorMessage":"must cancel subscription with storekit before deleting","messagePattern":"must cancel subscription with storekit before deleting","errorType":"exception","errorClass":"SubscriptionInvalidArgumentsException","httpStatus":400,"severity":"error","filePath":"service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java","lineNumber":105,"sourceCode":"   * <p>\n   * The App Store does not support backend cancellation, so this does not actually cancel, but it does verify that the\n   * user has no active subscriptions. End-users must cancel their subscription directly through storekit before calling\n   * this method.\n   *\n   * @param originalTransactionId The originalTransactionId associated with the subscription\n   * @throws RateLimitExceededException            If rate-limited\n   * @throws SubscriptionInvalidArgumentsException If the transaction is valid but does not contain a subscription, or\n   *                                                the transaction has not already been cancelled with storekit\n   */\n  @Override\n  public void cancelAllActiveSubscriptions(String originalTransactionId)\n      throws SubscriptionInvalidArgumentsException, RateLimitExceededException {\n    try {\n      final AppleAppStoreDecodedTransaction tx = lookupSubscription(originalTransactionId, Tags.of(LOOKUP_TYPE_TAG, \"cancel\"));\n      if (tx.signedTransaction().getStatus() != Status.EXPIRED &&\n          tx.signedTransaction().getStatus() != Status.REVOKED &&\n          tx.renewalInfo().getAutoRenewStatus() != AutoRenewStatus.OFF) {\n        throw new SubscriptionInvalidArgumentsException(\"must cancel subscription with storekit before deleting\");\n      }\n    } catch (SubscriptionNotFoundException _) {\n      // If the subscription is not found there is no need to do anything, so we can squash it\n    }\n    // The subscription will not auto-renew, so we can stop tracking it\n  }\n\n  @Override\n  public SubscriptionInformation getSubscriptionInformation(final String originalTransactionId)\n      throws RateLimitExceededException, SubscriptionNotFoundException {\n    final AppleAppStoreDecodedTransaction tx = lookupSubscription(originalTransactionId, Tags.of(LOOKUP_TYPE_TAG, \"info\"));\n    final SubscriptionStatus status = switch (tx.signedTransaction().getStatus()) {\n      case ACTIVE -> SubscriptionStatus.ACTIVE;\n      case BILLING_RETRY -> SubscriptionStatus.PAST_DUE;\n      case BILLING_GRACE_PERIOD -> SubscriptionStatus.UNPAID;\n      case EXPIRED, REVOKED -> SubscriptionStatus.CANCELED;\n    };\n","sourceCodeStart":87,"sourceCodeEnd":123,"githubUrl":"https://github.com/signalapp/Signal-Server/blob/100ab61c82627582c867d19e1c0561ba2781e927/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java#L87-L123","documentation":"cancelAllActiveSubscriptions refuses to stop tracking an Apple App Store subscription when the transaction is still alive: its status is neither EXPIRED nor REVOKED and auto-renew is still ON. Apple requires the subscription to be cancelled via StoreKit (App Store) before Signal drops it, otherwise the user would lose server-side tracking of a billing-active subscription.","triggerScenarios":"Calling account deletion / cancel-all-subscriptions flow with an originalTransactionId whose latest transaction status is ACTIVE/GRACE_PERIOD and whose renewalInfo autoRenewStatus is not OFF.","commonSituations":"User requests account deletion while their Signal subscription is still auto-renewing through the App Store; migration scripts or cleanup jobs run against active subscriptions.","solutions":["In the app, cancel the subscription via StoreKit (autoRenewDisabled = true) and wait for renewalInfo to show autoRenewStatus OFF before retrying deletion.","Point the user to App Store settings (Subscriptions) to cancel manually, then re-run the operation.","If the transaction should already be dead, re-fetch the latest transaction/signed renewal info — cached data may be stale.","Handle SubscriptionInvalidArgumentsException in the account-deletion UI by prompting the user to cancel IAP first."],"exampleFix":"// client: cancel via StoreKit before calling server delete\nlet options = Product.SubscriptionInfo.RenewalInfo...\nawait product.subscription?.renewalInfo // confirm autoRenewStatus == .off\nserver.cancelAllActiveSubscriptions(originalTransactionId)\n// server: caller should first verify\nif (tx.signedTransaction().getStatus() != Status.EXPIRED\n    && tx.signedTransaction().getStatus() != Status.REVOKED\n    && tx.renewalInfo().getAutoRenewStatus() != AutoRenewStatus.OFF) {\n  // prompt user to cancel in StoreKit first, do not call cancelAllActiveSubscriptions\n}","handlingStrategy":"validation","validationCode":"RenewalInfo info = fetchRenewalInfo(originalTransactionId);\nboolean cancellable = tx.getStatus() == Status.EXPIRED || tx.getStatus() == Status.REVOKED || info.getAutoRenewStatus() == AutoRenewStatus.OFF;\nif (!cancellable) { promptUserToCancelInStoreKit(); return; }","typeGuard":null,"tryCatchPattern":"try {\n  api.cancelAllActiveSubscriptions(originalTransactionId);\n} catch (SubscriptionInvalidArgumentsException e) {\n  showSubscriptionCancellationInstructions(); // direct user to App Store subscription settings\n}","preventionTips":["Check autoRenewStatus == OFF before requesting account deletion","Fetch fresh transaction/renewal info rather than relying on cached state","UI should detect active auto-renewing subscriptions early in deletion flows"],"tags":["app-store","storekit","subscription-cancellation","precondition"],"backgroundTag":"invalid-state-transition","analyzedSha":"100ab61c82627582c867d19e1c0561ba2781e927","analyzedAt":"2026-09-09T13:29:47.883Z","contentChangedAt":"2026-09-09T13:29:47.883Z","schemaVersion":2},"datasetVersion":"2026-09-16T09:17:16.951Z"}