{"record":{"id":"571a2d0729f2ae87","repo":"signalapp/Signal-Server","slug":"purchase-has-no-line-items","errorCode":null,"errorMessage":"purchase has no line items","messagePattern":"purchase has no line items","errorType":"exception","errorClass":"SubscriptionInvalidArgumentsException","httpStatus":400,"severity":"error","filePath":"service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java","lineNumber":369,"sourceCode":"            .flatMap(ConsumptionState::fromString)\n            .orElseThrow(() -> new IllegalStateException(\"Purchase did not contain a consumption state: \" + lineItem.getProductOfferDetails()));\n\n        if (consumptionState == ConsumptionState.YET_TO_BE_CONSUMED) {\n          // Mark this token as consumed\n          executeTokenOperation(publisher ->\n              publisher.purchases().products().consume(packageName, productId, purchaseToken));\n        }\n      }\n      return Optional.of(new PaymentDetails(purchaseToken, level, paymentStatus, purchaseTime, null));\n    } catch (SubscriptionNotFoundException e) {\n      return Optional.empty();\n    }\n  }\n\n  private ProductLineItem getLineItem(final ProductPurchaseV2 purchase) throws SubscriptionInvalidArgumentsException {\n    final List<ProductLineItem> lineItems = purchase.getProductLineItem();\n    if (lineItems == null || lineItems.isEmpty()) {\n      throw new SubscriptionInvalidArgumentsException(\"purchase has no line items\");\n    }\n    if (lineItems.size() > 1) {\n      logger.warn(\"{} line items found for purchase {}, expected 1\", lineItems.size(), purchase.getOrderId());\n    }\n    return lineItems.getFirst();\n  }\n\n  interface ApiCall<T> {\n\n    AndroidPublisherRequest<T> req(AndroidPublisher publisher) throws IOException;\n  }\n\n  /**\n   * Asynchronously execute a synchronous API call on a purchaseToken, mapping expected errors to the appropriate\n   * {@link SubscriptionException}\n   *\n   * @param apiCall An API call that operates on a purchaseToken\n   * @param <R>     The result of the API call","sourceCodeStart":351,"sourceCodeEnd":387,"githubUrl":"https://github.com/signalapp/Signal-Server/blob/100ab61c82627582c867d19e1c0561ba2781e927/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java#L351-L387","documentation":"getLineItem expects a ProductPurchaseV2 to contain exactly one product line item; a purchase with a null or empty productLineItem list cannot be mapped to a subscription/product state, so Signal throws SubscriptionInvalidArgumentsException. (If more than one exists it only logs a warning and uses the first.) An empty list means the Play API returned a purchase record without any active line item data.","triggerScenarios":"Calling purchase/lineItem/expired paths with a purchase token whose ProductPurchaseV2 response from the Google Play Developer API contains no productLineItem entries — typically an expired, voided, or malformed purchase.","commonSituations":"Purchase was refunded/voided so Play drops the line item; token submitted long after expiry; incorrect package/product configuration making the API return an empty purchase record; Play API version mismatch returning v2 shape differences.","solutions":["Treat this as an expired/voided purchase: have the user re-subscribe and submit a fresh purchase token.","Check the purchase's acknowledgement/voided state via the Play Developer API; voided purchases legitimately have no line items.","Verify the client sends the purchaseToken for the right product/package (wrong token can yield empty records).","If it is transient, re-query the Play API; occasionally line items appear only after purchase processing completes."],"exampleFix":"// before: trusting a cached/stale token\nclient.validateToken(stalePurchaseToken);\n// after: refresh purchase record first\nPurchaseResult p = billingClient.queryPurchasesAsync(product);\nif (!p.lineItems.isEmpty()) {\n  client.validateToken(p.purchaseToken);\n}","handlingStrategy":"validation","validationCode":"ProductPurchaseV2 p = playApi.getPurchase(token);\nif (p == null || p.getProductLineItem() == null || p.getProductLineItem().isEmpty()) {\n  treatAsExpiredOrVoided();\n  return;\n}","typeGuard":"function hasLineItems(purchase) { return Array.isArray(purchase?.productLineItem) && purchase.productLineItem.length > 0; }","tryCatchPattern":"try {\n  api.validateToken(purchaseToken);\n} catch (SubscriptionInvalidArgumentsException e) {\n  requireNewPurchase(); // line items are gone; purchase is expired/voided\n}","preventionTips":["Query the purchase record and check for line items before submitting the token","Treat voided/refunded purchases as ineligible and require repurchase","Ensure the purchase token belongs to the correct package/product"],"tags":["google-play-billing","line-items","empty-response","purchase-token"],"backgroundTag":"empty-result-set","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"}