diff --git a/node_modules/@capgo/native-purchases/android/src/main/java/ee/forgr/nativepurchases/NativePurchasesPlugin.java b/node_modules/@capgo/native-purchases/android/src/main/java/ee/forgr/nativepurchases/NativePurchasesPlugin.java index da1584f..813e719 100644 --- a/node_modules/@capgo/native-purchases/android/src/main/java/ee/forgr/nativepurchases/NativePurchasesPlugin.java +++ b/node_modules/@capgo/native-purchases/android/src/main/java/ee/forgr/nativepurchases/NativePurchasesPlugin.java @@ -693,14 +693,41 @@ public class NativePurchasesPlugin extends Plugin { ProductDetails.SubscriptionOfferDetails selectedOfferDetails = null; assert productDetailsItem.getSubscriptionOfferDetails() != null; Log.d(TAG, "Available offer details count: " + productDetailsItem.getSubscriptionOfferDetails().size()); + // CestaChef (PASO-15): sin token, el PLAN BASE (sin oferta). Play devuelve a todo + // el mundo las ofertas «determinadas por el desarrollador» (las de afiliado): + // coger la primera del plan base podía aplicar el descuento de un código a quien + // no lo tiene. for (ProductDetails.SubscriptionOfferDetails offerDetails : productDetailsItem.getSubscriptionOfferDetails()) { Log.d(TAG, "Checking offer: " + offerDetails.getBasePlanId()); - if (offerDetails.getBasePlanId().equals(planIdentifier)) { + if (offerDetails.getBasePlanId().equals(planIdentifier) && offerDetails.getOfferId() == null) { selectedOfferDetails = offerDetails; - Log.d(TAG, "Found matching plan: " + planIdentifier); + Log.d(TAG, "Found matching base plan without offer: " + planIdentifier); break; } } + // CestaChef (patch-package): si se pasa el token de una oferta concreta + // (la prueba gratis), se usa ESA. Sin esto el plugin cogía la primera + // oferta del plan base, que según el orden de Play era la de sin prueba. + if (offerToken != null && !offerToken.isEmpty()) { + ProductDetails.SubscriptionOfferDetails ofertaPedida = null; + for (ProductDetails.SubscriptionOfferDetails offerDetails : productDetailsItem.getSubscriptionOfferDetails()) { + if (offerToken.equals(offerDetails.getOfferToken())) { + ofertaPedida = offerDetails; + Log.d(TAG, "Using requested offer token for subscription"); + break; + } + } + // CestaChef (PASO-15): una oferta pedida que ya no está (caducada, retirada + // o el usuario dejó de ser elegible) se RECHAZA. Caer al plan base cobraría + // el precio normal a quien vio un descuento; la app vuelve a preguntar. + if (ofertaPedida == null) { + Log.d(TAG, "Requested subscription offer token not found"); + closeBillingClient(); + call.reject("OFFER_TOKEN_NOT_FOUND", "OFFER_TOKEN_NOT_FOUND"); + return; + } + selectedOfferDetails = ofertaPedida; + } if (selectedOfferDetails == null) { selectedOfferDetails = productDetailsItem.getSubscriptionOfferDetails().get(0); Log.d(TAG, "Using first available offer: " + selectedOfferDetails.getBasePlanId()); @@ -728,8 +755,24 @@ public class NativePurchasesPlugin extends Plugin { productDetailsParams.setOfferToken(selectedOffer.getOfferToken()); Log.d(TAG, "Set one-time offer token: " + selectedOffer.getOfferToken()); } else if (productDetailsItem.getOneTimePurchaseOfferDetails() == null && !oneTimeOffers.isEmpty()) { - productDetailsParams.setOfferToken(oneTimeOffers.get(0).getOfferToken()); - Log.d(TAG, "Set default one-time offer token: " + oneTimeOffers.get(0).getOfferToken()); + // CestaChef (PASO-15): sin token, la opción de compra SIN oferta. Las ofertas + // de descuento de un producto único no se pueden limitar a quien tiene código: + // la primera de la lista podía ser una rebaja que se aplicaría a cualquiera. + ProductDetails.OneTimePurchaseOfferDetails sinOferta = null; + for (ProductDetails.OneTimePurchaseOfferDetails offerDetails : oneTimeOffers) { + if (offerDetails.getOfferId() == null) { + sinOferta = offerDetails; + break; + } + } + if (sinOferta == null) { + Log.d(TAG, "No base purchase option without offer for: " + productIdentifier); + closeBillingClient(); + call.reject("BASE_PURCHASE_OPTION_NOT_FOUND", "BASE_PURCHASE_OPTION_NOT_FOUND"); + return; + } + productDetailsParams.setOfferToken(sinOferta.getOfferToken()); + Log.d(TAG, "Set base one-time offer token: " + sinOferta.getOfferToken()); } } productDetailsParamsList.add(productDetailsParams.build()); @@ -884,7 +927,14 @@ public class NativePurchasesPlugin extends Plugin { return; } + // CestaChef (PASO-15): el precio que se enseña es el de la opción SIN oferta. ProductDetails.OneTimePurchaseOfferDetails selectedOffer = oneTimeOffers.get(0); + for (ProductDetails.OneTimePurchaseOfferDetails offerDetails : oneTimeOffers) { + if (offerDetails.getOfferId() == null) { + selectedOffer = offerDetails; + break; + } + } product.put("identifier", productDetails.getProductId()); ProductPayloadMapper.applyOneTimePurchaseOfferPricing(product, selectedOffer); Log.d(TAG, "Price: " + product.optDouble("price", 0.0)); @@ -1055,6 +1105,21 @@ public class NativePurchasesPlugin extends Plugin { product.put("isFamilyShareable", false); ProductPayloadMapper.applySubscriptionPricing(product, offerDetails, offerDetailsList); + // CestaChef (PASO-15): fases y etiquetas de la oferta, para elegir la del + // afiliado y enseñar el precio que cobrará Google, no uno calculado. + org.json.JSONArray fases = new org.json.JSONArray(); + for (ProductDetails.PricingPhase f : offerDetails.getPricingPhases().getPricingPhaseList()) { + JSObject fase = new JSObject(); + fase.put("priceAmountMicros", f.getPriceAmountMicros()); + fase.put("formattedPrice", f.getFormattedPrice()); + fase.put("currencyCode", f.getPriceCurrencyCode()); + fase.put("billingPeriod", f.getBillingPeriod()); + fase.put("billingCycleCount", f.getBillingCycleCount()); + fase.put("recurrenceMode", f.getRecurrenceMode()); + fases.put(fase); + } + product.put("pricingPhases", fases); + product.put("offerTags", new org.json.JSONArray(offerDetails.getOfferTags())); double price = product.optDouble("price", 0.0); Log.d(TAG, "Plan identifier: " + productDetails.getProductId()); diff --git a/node_modules/@capgo/native-purchases/dist/esm/definitions.d.ts b/node_modules/@capgo/native-purchases/dist/esm/definitions.d.ts index 63472f2..fc5831d 100644 --- a/node_modules/@capgo/native-purchases/dist/esm/definitions.d.ts +++ b/node_modules/@capgo/native-purchases/dist/esm/definitions.d.ts @@ -912,6 +912,17 @@ export interface Product { * Android: offer identifier (null/undefined for base offers). */ readonly offerId?: string | null; + /** CestaChef (PASO-15), solo Android: fases de precio de la oferta tal como las da Play. */ + readonly pricingPhases?: { + priceAmountMicros: number; + formattedPrice: string; + currencyCode: string; + billingPeriod: string; + billingCycleCount: number; + recurrenceMode: number; + }[]; + /** CestaChef (PASO-15), solo Android: etiquetas de la oferta en Play Console. */ + readonly offerTags?: string[]; /** * Android one-time products only: purchase option identifier (for example `buy-option`). * @@ -1346,6 +1357,8 @@ export interface NativePurchasesPlugin { getStorefront(): Promise<{ countryCode: string; storefrontId?: string; + /** CestaChef (PASO-15), solo iOS: `AppStore.deviceVerificationID`. */ + deviceVerificationID?: string; }>; /** * Listen for StoreKit transaction updates delivered by Apple's Transaction.updates. diff --git a/node_modules/@capgo/native-purchases/ios/Sources/NativePurchasesPlugin/NativePurchasesPlugin.swift b/node_modules/@capgo/native-purchases/ios/Sources/NativePurchasesPlugin/NativePurchasesPlugin.swift index e4ab9e1..1e84a6d 100644 --- a/node_modules/@capgo/native-purchases/ios/Sources/NativePurchasesPlugin/NativePurchasesPlugin.swift +++ b/node_modules/@capgo/native-purchases/ios/Sources/NativePurchasesPlugin/NativePurchasesPlugin.swift @@ -79,10 +79,16 @@ public class NativePurchasesPlugin: CAPPlugin, CAPBridgedPlugin { let storefront = await Storefront.current await MainActor.run { if let storefront = storefront { - call.resolve([ + // CestaChef (PASO-15): id de verificación del dispositivo, para demostrar al + // servidor que un canje hecho fuera de la app es de este móvil. + var respuesta: [String: Any] = [ "countryCode": storefront.countryCode, "storefrontId": storefront.id - ]) + ] + if let idDispositivo = AppStore.deviceVerificationID { + respuesta["deviceVerificationID"] = idDispositivo.uuidString + } + call.resolve(respuesta) } else { // No storefront (e.g. alternative distribution). print("getStorefront: no storefront available")