j3k0 / cordova-plugin-purchase

In-App Purchase for Cordova on iOS, Android and Windows
https://purchase.cordova.fovea.cc
1.3k stars 535 forks source link

iOS - "Missing additionalData.appStore.discount when ordering a discount offer" #1600

Open Ralle opened 1 month ago

Ralle commented 1 month ago

Observed behavior

From July 14 to Aug 14 we had zero iOS sign ups. I checked my log and everyone had been getting this error:

Missing additionalData.appStore.discount when ordering a discount offer

It was not related to a new version of our app.

It seemed like the order of the offers was wrong. It wasn't getting the default offer. I fixed it this way: Old:

const offer = await product?.getOffer();

New:

const offer = await product?.getOffer(
  product.platform == CdvPurchase.Platform.APPLE_APPSTORE ? "$" : undefined
)

But people still get this error once in a while, only on iOS, confirmed to be the new version of the app with my fix applied.

Any suggestions?

j3k0 commented 1 month ago

getOffer() just returns the first offer in the list of offers (which on iOS is generally the only one). Google Play allows multiple offers for the same product. On iOS there will be a single offer. A discount offer is handled a bit differently, it requires a server that generates signed token that gives access to discount offers.

I'm not really sure what you're trying to achieve, can you share your full source code (related to in-app purchases) and log outputs of a purchase session with verbosity set to debug in the plugin.

Ralle commented 1 month ago

I am not doing anything particularly exotic. In fact this code worked fine for more than a year but out of the blue it started returning a different offer.

// Setup (This is React)

useEffect(() => {
  if (!isNative()) {
    return;
  }
  (async () => {
    try {
      CdvPurchase.store.verbosity = CdvPurchase.LogLevel.DEBUG;

      IAP_PRODUCT_IDS.forEach((productId) => {
        const p: CdvPurchase.IRegisterProduct = {
          id: productId,
          platform: CdvPurchase.Platform.APPLE_APPSTORE,
          type: CdvPurchase.ProductType.PAID_SUBSCRIPTION,
        };
        CdvPurchase.store.register(p);
        const p2: CdvPurchase.IRegisterProduct = {
          id: productId,
          platform: CdvPurchase.Platform.GOOGLE_PLAY,
          type: CdvPurchase.ProductType.PAID_SUBSCRIPTION,
        };
        CdvPurchase.store.register(p2);
      });

      CdvPurchase.store.error((err) => {
        console.log("Store error", err);
        errorSender.current.sendError({
          name: "CdvPurchase Error",
          message: err.message,
          stack: "",
          time: new Date(),
          context: {
            code: err.code,
            isError: err.isError,
          },
        });
      });

      CdvPurchase.store.validator = `${Env.ApiPrefix}/validate-iap-receipt`;
      CdvPurchase.store.verbosity = CdvPurchase.LogLevel.DEBUG;

      await CdvPurchase.store.initialize([
        CdvPurchase.Platform.APPLE_APPSTORE,
        CdvPurchase.Platform.GOOGLE_PLAY,
      ]);

      // Load products
      const newProducts = new Map(
        CdvPurchase.store.products.map((p) => [p.id, p])
      );
      setProducts(newProducts);
      // End load products

      // Very important to reload currencies/etc when store is initialized
      setLanguageByCode(localStorage.language ?? getBrowserLanguage(), true);

      CdvPurchase.store.when().approved(async (transaction) => {
        setOrderProcessing(true);
        console.log(
          `GlobalContextProvider - Transaction approved - transactionId: ${transaction.transactionId}`,
          transaction
        );
        transaction.verify();
      });

      CdvPurchase.store.when().unverified(async () => {
        setOrderProcessing(false);
      });
      CdvPurchase.store.when().verified(async (verifiedReceipt) => {
        setOrderProcessing(false);
        const receipt = verifiedReceipt.sourceReceipt;
        // A lot of code to verify receipt, etc
      });
    } catch (e) {
      if (e instanceof Error) {
        errorSender.current.sendError({
          name: e.name,
          message: e.message,
          stack: e.stack,
          time: new Date(),
          context: {},
        });
      }
    }
  })();
}, []);

// When they click sign up

const product = CdvPurchase.store.get(getPlanAlias(plan));
const offer = await product?.getOffer(
  product.platform == CdvPurchase.Platform.APPLE_APPSTORE ? "$" : undefined
); // DEFAULT_OFFER_ID
if (!offer) {
  global.trackError({
    name: "Native store error",
    message: "Cannot find products or offers",
    stack: "",
    context: {
      product,
      products: CdvPurchase.store.products,
    },
    time: new Date(),
  });

  setError(global.translate("cannot_start_purchase"));
  setProcessing(false);
  return;
}
console.log("offer.order()");
const purchaseResult = await offer.order();
console.log("offer.order() after");
if (purchaseResult?.isError) {
  if (purchaseResult.message === "USER_CANCELED") {
    setError(global.translate("purchase_aborted"));
  } else {
    setError(purchaseResult.message);
  }
  setProcessing(false);
} else {
  setError("");
  setProcessing(false);
}
};

It happened again Sep 21:

billede
j3k0 commented 1 month ago

First thing I noticed: you shouldn't initialize before setting up the events handlers (store.when goes before store.initialize) -- I wonder how that works because, IIRC, the "initialize" promise should only resolve when pending transactions in the queue have been processed (finished, verified or unverified). At the very least, you might be missing some events.

This is the code in the plugin that emits that particular error:

const discountId = offer.id !== DEFAULT_OFFER_ID ? offer.id : undefined;
const discount = additionalData?.appStore?.discount;
if (discountId && !discount) {
    return callResolve(appStoreError(ErrorCode.MISSING_OFFER_PARAMS, 'Missing additionalData.appStore.discount when ordering a discount offer', offer.productId));
}

With const DEFAULT_OFFER_ID = '$'.

_(Side note, you can access that constant as CdvPurchase.AppleAppStore.DEFAULT_OFFER_ID)_

So somehow offerId passed is not "$"... Could you add more logs? (Log the full "product" object) and the "offer" object being ordered?

Ralle commented 1 month ago

I will move initialize to the end and log the whole purchase attempt on the next build. We plan to release it today, so I might be a few days until I have some logs for you.

Thank you for responding.