{"record":{"id":"703d272b2360580e","repo":"signalapp/Signal-Server","slug":"purchase-was-for-an-unexpected-product","errorCode":null,"errorMessage":"purchase was for an unexpected product","messagePattern":"purchase was for an unexpected product","errorType":"exception","errorClass":"SubscriptionInvalidArgumentsException","httpStatus":null,"severity":"error","filePath":"service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/LoginPurchaseManager.java","lineNumber":79,"sourceCode":"      throws RateLimitExceededException, SubscriptionInvalidArgumentsException, IOException, SubscriptionNotFoundException, SubscriptionReceiptRequestedForOpenPaymentException, SubscriptionPaymentRequiredException, SubscriptionReceiptAlreadyRedeemedException, VerificationFailedException {\n\n    final OneTimePaymentProcessor oneTimePaymentProcessor = oneTimePaymentProcessors.get(paymentProvider);\n    if (oneTimePaymentProcessor == null) {\n      throw new SubscriptionInvalidArgumentsException(\"unknown payment provider: \" + paymentProvider);\n    }\n\n    final PaymentDetails paymentDetails = oneTimePaymentProcessor\n        .claimOneTimePurchase(purchaseId)\n        .orElseThrow(SubscriptionNotFoundException::new);\n    if (paymentDetails.status() == PaymentStatus.PROCESSING) {\n      throw new SubscriptionReceiptRequestedForOpenPaymentException();\n    } else if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {\n      throw Optional.ofNullable(paymentDetails.chargeFailure())\n          .<SubscriptionPaymentRequiredException>map(\n              cf -> new SubscriptionChargeFailurePaymentRequiredException(paymentProvider, cf))\n          .orElseGet(SubscriptionPaymentRequiredException::new);\n    } else if (paymentDetails.level() != ReceiptLevel.LOGIN) {\n      throw new SubscriptionInvalidArgumentsException(\"purchase was for an unexpected product\");\n    }\n\n    // Calculating the expiration from the creation date works for IAP purchases. However, for other processors, the\n    // creation date of the payment intent might be days before the payment actually completed. If we support non-IAP\n    // processors we should attempt to get the latest date. see OneTimeDonationController/OneTimeDonationManager\n    final Instant expiration = paymentDetails.created().plus(LOGIN_EXPIRATION).truncatedTo(ChronoUnit.DAYS);\n\n    try {\n      issuedReceiptsManager.recordOneTimeIssuance(paymentDetails.id(), paymentProvider, receiptCredentialRequest,\n          expiration);\n    } catch (WriteConflictException _) {\n      throw new SubscriptionReceiptAlreadyRedeemedException();\n    }\n\n    return zkReceiptOperations.issueReceiptCredential(receiptCredentialRequest, expiration.getEpochSecond(),\n        ReceiptLevel.LOGIN.getValue());\n  }\n}","sourceCodeStart":61,"sourceCodeEnd":97,"githubUrl":"https://github.com/signalapp/Signal-Server/blob/100ab61c82627582c867d19e1c0561ba2781e927/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/LoginPurchaseManager.java#L61-L97","documentation":"generateReceipt validates that a successfully paid one-time purchase grants the LOGIN receipt level. If paymentDetails.status() is SUCCEEDED but paymentDetails.level() != ReceiptLevel.LOGIN, the purchase bought a different product (e.g. a donation badge) and cannot be redeemed for login entitlement, so Signal throws SubscriptionInvalidArgumentsException.","triggerScenarios":"Claiming a one-time purchase whose configured product/level maps to a non-LOGIN receipt level (e.g. a donation-tier product) and then requesting a login receipt with it.","commonSituations":"Client sends the purchase id of a donation/one-time-donation product into the login purchase flow; server product-to-level mapping changed after the purchase; mixing the donations API with the login purchase API.","solutions":["Ensure the user purchases the correct login product (the one mapped to ReceiptLevel.LOGIN) and send that purchase id.","Use the correct redemption flow for non-login products (donation flow) instead of generateReceipt for login.","Verify server-side product-to-level configuration matches the product ids the client offers.","Handle SubscriptionInvalidArgumentsException client-side by pointing the user to buy the login-specific product."],"exampleFix":"// before: redeeming a donation purchase for login\nclient.generateReceipt(purchaseIdOfDonationProduct);\n// after\nif (productLevel == ReceiptLevel.LOGIN) {\n  client.generateReceipt(purchaseId);\n} else {\n  promptPurchaseOfLoginProduct();\n}","handlingStrategy":"validation","validationCode":"// confirm the claimed purchase maps to the LOGIN level before generating a receipt\nif (paymentDetails.level() != ReceiptLevel.LOGIN) { promptPurchaseOfLoginProduct(); return; }","typeGuard":"function isLoginLevel(details) { return details?.level === 'LOGIN'; }","tryCatchPattern":"try {\n  api.generateReceipt(paymentProvider, purchaseId, request);\n} catch (SubscriptionInvalidArgumentsException e) {\n  showWrongProductError(); // purchase was not for the login product\n}","preventionTips":["Verify the purchased product maps to ReceiptLevel.LOGIN server-side before redeeming","Keep product-to-level configuration consistent across deploys","Route donation/other-level products through their own redemption flows"],"tags":["receipt-level","one-time-purchase","product-mismatch","argument-validation"],"backgroundTag":"invalid-argument-value","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"}