From d1a743d046559eb2d885be8beb902422540d628b Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 17 Jan 2026 08:19:30 +0900 Subject: [PATCH 1/7] feat: sync with openiap v1.3.12 - Update openiap-versions.json (gql: 1.3.12, apple: 1.3.10, google: 1.3.22) - Regenerate TypeScript types with new cross-platform offer types - New DiscountOffer and SubscriptionOffer types available - New discountOffers and subscriptionOffers fields on Product types Co-Authored-By: Claude Opus 4.5 --- openiap-versions.json | 6 +- src/types.ts | 251 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 253 insertions(+), 4 deletions(-) diff --git a/openiap-versions.json b/openiap-versions.json index b45867fd3..474c80c32 100644 --- a/openiap-versions.json +++ b/openiap-versions.json @@ -1,6 +1,6 @@ { - "apple": "1.3.9", - "google": "1.3.21", - "gql": "1.3.11", + "apple": "1.3.10", + "google": "1.3.22", + "gql": "1.3.12", "docs": "1.3.7" } diff --git a/src/types.ts b/src/types.ts index d6cb5e4ef..82a881f2e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -169,6 +169,11 @@ export interface DiscountDisplayInfoAndroid { percentageDiscount?: (number | null); } +/** + * Discount information returned from the store. + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. + * @see https://openiap.dev/docs/types#subscription-offer + */ export interface DiscountIOS { identifier: string; localizedPrice?: (string | null); @@ -180,6 +185,79 @@ export interface DiscountIOS { type: string; } +/** + * Standardized one-time product discount offer. + * Provides a unified interface for one-time purchase discounts across platforms. + * + * Currently supported on Android (Google Play Billing 7.0+). + * iOS does not support one-time purchase discounts in the same way. + * + * @see https://openiap.dev/docs/features/discount + */ +export interface DiscountOffer { + /** Currency code (ISO 4217, e.g., "USD") */ + currency: string; + /** + * [Android] Fixed discount amount in micro-units. + * Only present for fixed amount discounts. + */ + discountAmountMicrosAndroid?: (string | null); + /** Formatted display price string (e.g., "$4.99") */ + displayPrice: string; + /** [Android] Formatted discount amount string (e.g., "$5.00 OFF"). */ + formattedDiscountAmountAndroid?: (string | null); + /** + * [Android] Original full price in micro-units before discount. + * Divide by 1,000,000 to get the actual price. + * Use for displaying strikethrough original price. + */ + fullPriceMicrosAndroid?: (string | null); + /** + * Unique identifier for the offer. + * - iOS: Not applicable (one-time discounts not supported) + * - Android: offerId from ProductAndroidOneTimePurchaseOfferDetail + */ + id?: (string | null); + /** + * [Android] Limited quantity information. + * Contains maximumQuantity and remainingQuantity. + */ + limitedQuantityInfoAndroid?: (LimitedQuantityInfoAndroid | null); + /** [Android] List of tags associated with this offer. */ + offerTagsAndroid?: (string[] | null); + /** + * [Android] Offer token required for purchase. + * Must be passed to requestPurchase() when purchasing with this offer. + */ + offerTokenAndroid?: (string | null); + /** + * [Android] Percentage discount (e.g., 33 for 33% off). + * Only present for percentage-based discounts. + */ + percentageDiscountAndroid?: (number | null); + /** + * [Android] Pre-order details if this is a pre-order offer. + * Available in Google Play Billing Library 8.1.0+ + */ + preorderDetailsAndroid?: (PreorderDetailsAndroid | null); + /** Numeric price value */ + price: number; + /** [Android] Rental details if this is a rental offer. */ + rentalDetailsAndroid?: (RentalDetailsAndroid | null); + /** Type of discount offer */ + type: DiscountOfferType; + /** + * [Android] Valid time window for the offer. + * Contains startTimeMillis and endTimeMillis. + */ + validTimeWindowAndroid?: (ValidTimeWindowAndroid | null); +} + +/** + * iOS DiscountOffer (output type). + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. + * @see https://openiap.dev/docs/types#subscription-offer + */ export interface DiscountOfferIOS { /** Discount identifier */ identifier: string; @@ -206,6 +284,12 @@ export interface DiscountOfferInputIOS { timestamp: number; } +/** + * Discount offer type enumeration. + * Categorizes the type of discount or promotional offer. + */ +export type DiscountOfferType = 'introductory' | 'promotional' | 'one-time'; + export interface EntitlementIOS { jsonRepresentation: string; sku: string; @@ -517,6 +601,12 @@ export type MutationVerifyPurchaseArgs = VerifyPurchaseProps; export type MutationVerifyPurchaseWithProviderArgs = VerifyPurchaseWithProviderProps; +/** + * Payment mode for subscription offers. + * Determines how the user pays during the offer period. + */ +export type PaymentMode = 'free-trial' | 'pay-as-you-go' | 'pay-up-front' | 'unknown'; + export type PaymentModeIOS = 'empty' | 'free-trial' | 'pay-as-you-go' | 'pay-up-front'; /** @@ -555,6 +645,12 @@ export interface ProductAndroid extends ProductCommon { currency: string; debugDescription?: (string | null); description: string; + /** + * Standardized discount offers for one-time products. + * Cross-platform type with Android-specific fields using suffix. + * @see https://openiap.dev/docs/types#discount-offer + */ + discountOffers?: (DiscountOffer[] | null); displayName?: (string | null); displayPrice: string; id: string; @@ -562,18 +658,32 @@ export interface ProductAndroid extends ProductCommon { /** * One-time purchase offer details including discounts (Android) * Returns all eligible offers. Available in Google Play Billing Library 7.0+ + * @deprecated Use discountOffers instead for cross-platform compatibility. + * @deprecated Use discountOffers instead */ oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); platform: 'android'; price?: (number | null); + /** + * @deprecated Use subscriptionOffers instead for cross-platform compatibility. + * @deprecated Use subscriptionOffers instead + */ subscriptionOfferDetailsAndroid?: (ProductSubscriptionAndroidOfferDetails[] | null); + /** + * Standardized subscription offers. + * Cross-platform type with Android-specific fields using suffix. + * @see https://openiap.dev/docs/types#subscription-offer + */ + subscriptionOffers?: (SubscriptionOffer[] | null); title: string; type: 'in-app'; } /** - * One-time purchase offer details (Android) + * One-time purchase offer details (Android). * Available in Google Play Billing Library 7.0+ + * @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility. + * @see https://openiap.dev/docs/types#discount-offer */ export interface ProductAndroidOneTimePurchaseOfferDetail { /** @@ -633,7 +743,18 @@ export interface ProductIOS extends ProductCommon { jsonRepresentationIOS: string; platform: 'ios'; price?: (number | null); + /** + * @deprecated Use subscriptionOffers instead for cross-platform compatibility. + * @deprecated Use subscriptionOffers instead + */ subscriptionInfoIOS?: (SubscriptionInfoIOS | null); + /** + * Standardized subscription offers. + * Cross-platform type with iOS-specific fields using suffix. + * Note: iOS does not support one-time product discounts. + * @see https://openiap.dev/docs/types#subscription-offer + */ + subscriptionOffers?: (SubscriptionOffer[] | null); title: string; type: 'in-app'; typeIOS: ProductTypeIOS; @@ -654,6 +775,12 @@ export interface ProductSubscriptionAndroid extends ProductCommon { currency: string; debugDescription?: (string | null); description: string; + /** + * Standardized discount offers for one-time products. + * Cross-platform type with Android-specific fields using suffix. + * @see https://openiap.dev/docs/types#discount-offer + */ + discountOffers?: (DiscountOffer[] | null); displayName?: (string | null); displayPrice: string; id: string; @@ -661,15 +788,32 @@ export interface ProductSubscriptionAndroid extends ProductCommon { /** * One-time purchase offer details including discounts (Android) * Returns all eligible offers. Available in Google Play Billing Library 7.0+ + * @deprecated Use discountOffers instead for cross-platform compatibility. + * @deprecated Use discountOffers instead */ oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); platform: 'android'; price?: (number | null); + /** + * @deprecated Use subscriptionOffers instead for cross-platform compatibility. + * @deprecated Use subscriptionOffers instead + */ subscriptionOfferDetailsAndroid: ProductSubscriptionAndroidOfferDetails[]; + /** + * Standardized subscription offers. + * Cross-platform type with Android-specific fields using suffix. + * @see https://openiap.dev/docs/types#subscription-offer + */ + subscriptionOffers: SubscriptionOffer[]; title: string; type: 'subs'; } +/** + * Subscription offer details (Android). + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. + * @see https://openiap.dev/docs/types#subscription-offer + */ export interface ProductSubscriptionAndroidOfferDetails { basePlanId: string; offerId?: (string | null); @@ -682,6 +826,10 @@ export interface ProductSubscriptionIOS extends ProductCommon { currency: string; debugDescription?: (string | null); description: string; + /** + * @deprecated Use subscriptionOffers instead for cross-platform compatibility. + * @deprecated Use subscriptionOffers instead + */ discountsIOS?: (DiscountIOS[] | null); displayName?: (string | null); displayNameIOS: string; @@ -696,7 +844,17 @@ export interface ProductSubscriptionIOS extends ProductCommon { jsonRepresentationIOS: string; platform: 'ios'; price?: (number | null); + /** + * @deprecated Use subscriptionOffers instead for cross-platform compatibility. + * @deprecated Use subscriptionOffers instead + */ subscriptionInfoIOS?: (SubscriptionInfoIOS | null); + /** + * Standardized subscription offers. + * Cross-platform type with iOS-specific fields using suffix. + * @see https://openiap.dev/docs/types#subscription-offer + */ + subscriptionOffers?: (SubscriptionOffer[] | null); subscriptionPeriodNumberIOS?: (string | null); subscriptionPeriodUnitIOS?: (SubscriptionPeriodIOS | null); title: string; @@ -1167,6 +1325,86 @@ export interface SubscriptionInfoIOS { subscriptionPeriod: SubscriptionPeriodValueIOS; } +/** + * Standardized subscription discount/promotional offer. + * Provides a unified interface for subscription offers across iOS and Android. + * + * Both platforms support subscription offers with different implementations: + * - iOS: Introductory offers, promotional offers with server-side signatures + * - Android: Offer tokens with pricing phases + * + * @see https://openiap.dev/docs/types/ios#discount-offer + * @see https://openiap.dev/docs/types/android#subscription-offer + */ +export interface SubscriptionOffer { + /** + * [Android] Base plan identifier. + * Identifies which base plan this offer belongs to. + */ + basePlanIdAndroid?: (string | null); + /** Currency code (ISO 4217, e.g., "USD") */ + currency?: (string | null); + /** Formatted display price string (e.g., "$9.99/month") */ + displayPrice: string; + /** + * Unique identifier for the offer. + * - iOS: Discount identifier from App Store Connect + * - Android: offerId from ProductSubscriptionAndroidOfferDetails + */ + id: string; + /** + * [iOS] Key identifier for signature validation. + * Used with server-side signature generation for promotional offers. + */ + keyIdentifierIOS?: (string | null); + /** [iOS] Localized price string. */ + localizedPriceIOS?: (string | null); + /** + * [iOS] Cryptographic nonce (UUID) for signature validation. + * Must be generated server-side for each purchase attempt. + */ + nonceIOS?: (string | null); + /** [iOS] Number of billing periods for this discount. */ + numberOfPeriodsIOS?: (number | null); + /** [Android] List of tags associated with this offer. */ + offerTagsAndroid?: (string[] | null); + /** + * [Android] Offer token required for purchase. + * Must be passed to requestPurchase() when purchasing with this offer. + */ + offerTokenAndroid?: (string | null); + /** Payment mode during the offer period */ + paymentMode?: (PaymentMode | null); + /** Subscription period for this offer */ + period?: (SubscriptionPeriod | null); + /** Number of periods the offer applies */ + periodCount?: (number | null); + /** Numeric price value */ + price: number; + /** + * [Android] Pricing phases for this subscription offer. + * Contains detailed pricing information for each phase (trial, intro, regular). + */ + pricingPhasesAndroid?: (PricingPhasesAndroid | null); + /** + * [iOS] Server-generated signature for promotional offer validation. + * Required when applying promotional offers on iOS. + */ + signatureIOS?: (string | null); + /** + * [iOS] Timestamp when the signature was generated. + * Used for signature validation. + */ + timestampIOS?: (number | null); + /** Type of subscription offer (Introductory or Promotional) */ + type: DiscountOfferType; +} + +/** + * iOS subscription offer details. + * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. + * @see https://openiap.dev/docs/types#subscription-offer + */ export interface SubscriptionOfferIOS { displayPrice: string; id: string; @@ -1179,8 +1417,19 @@ export interface SubscriptionOfferIOS { export type SubscriptionOfferTypeIOS = 'introductory' | 'promotional'; +/** Subscription period value combining unit and count. */ +export interface SubscriptionPeriod { + /** The period unit (day, week, month, year) */ + unit: SubscriptionPeriodUnit; + /** The number of units (e.g., 1 for monthly, 3 for quarterly) */ + value: number; +} + export type SubscriptionPeriodIOS = 'day' | 'week' | 'month' | 'year' | 'empty'; +/** Subscription period unit for cross-platform use. */ +export type SubscriptionPeriodUnit = 'day' | 'week' | 'month' | 'year' | 'unknown'; + export interface SubscriptionPeriodValueIOS { unit: SubscriptionPeriodIOS; value: number; From 4c8956b8998f6e7a6abfa43175136b8a823f391f Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 17 Jan 2026 08:41:50 +0900 Subject: [PATCH 2/7] feat: add cross-platform subscriptionOffers and discountOffers types - Add subscriptionOffers and discountOffers to NitroProduct interface - Update iOS RnIapHelper.swift to extract standardized offers from OpenIAP - Update Android HybridRnIap.kt with serialization for new offer types - Update type-bridge.ts to parse new JSON offer fields - Add comprehensive tests for new SubscriptionOffer and DiscountOffer types - Update documentation with new cross-platform offer types (v14.8.0+) - Update llms.txt and llms-full.txt AI reference documentation - Update example AllProducts screen to display standardized offers These new types provide a unified API for subscription and discount offers across iOS and Android, replacing the deprecated platform-specific fields subscriptionInfoIOS and subscriptionOfferDetailsAndroid. Co-Authored-By: Claude Opus 4.5 --- .../java/com/margelo/nitro/iap/HybridRnIap.kt | 100 ++++++++++ docs/docs/api/types.md | 114 ++++++++++++ docs/static/llms-full.txt | 60 +++++- docs/static/llms.txt | 46 ++++- example/screens/AllProducts.tsx | 123 +++++++++++++ ios/RnIapHelper.swift | 24 +++ src/__tests__/utils/type-bridge.test.ts | 173 ++++++++++++++++++ src/specs/RnIap.nitro.ts | 5 + src/utils/type-bridge.ts | 48 +++++ 9 files changed, 690 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index bab943e7f..cc7fa5c93 100644 --- a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -837,6 +837,84 @@ class HybridRnIap : HybridRnIapSpec() { return array.toString() } + /** + * Serialize standardized SubscriptionOffer list to JSON string (OpenIAP 1.3.10+) + */ + private fun serializeStandardizedSubscriptionOffers(offers: List): String { + val array = JSONArray() + offers.forEach { offer -> + val offerJson = JSONObject() + offerJson.put("id", offer.id) + offerJson.put("displayPrice", offer.displayPrice) + offerJson.put("price", offer.price) + offerJson.put("type", offer.type.rawValue) + offer.currency?.let { offerJson.put("currency", it) } + offer.basePlanIdAndroid?.let { offerJson.put("basePlanIdAndroid", it) } + offer.offerTokenAndroid?.let { offerJson.put("offerTokenAndroid", it) } + offer.offerTagsAndroid?.let { offerJson.put("offerTagsAndroid", JSONArray(it)) } + offer.paymentMode?.let { offerJson.put("paymentMode", it.rawValue) } + offer.periodCount?.let { offerJson.put("periodCount", it) } + offer.numberOfPeriodsIOS?.let { offerJson.put("numberOfPeriodsIOS", it) } + offer.period?.let { period -> + val periodJson = JSONObject() + periodJson.put("unit", period.unit.rawValue) + periodJson.put("value", period.value) + offerJson.put("period", periodJson) + } + offer.pricingPhasesAndroid?.let { phases -> + val phasesJson = JSONObject() + val phaseList = JSONArray() + phases.pricingPhaseList.forEach { phase -> + val phaseJson = JSONObject() + phaseJson.put("billingCycleCount", phase.billingCycleCount) + phaseJson.put("billingPeriod", phase.billingPeriod) + phaseJson.put("formattedPrice", phase.formattedPrice) + phaseJson.put("priceAmountMicros", phase.priceAmountMicros) + phaseJson.put("priceCurrencyCode", phase.priceCurrencyCode) + phaseJson.put("recurrenceMode", phase.recurrenceMode) + phaseList.put(phaseJson) + } + phasesJson.put("pricingPhaseList", phaseList) + offerJson.put("pricingPhasesAndroid", phasesJson) + } + array.put(offerJson) + } + return array.toString() + } + + /** + * Serialize standardized DiscountOffer list to JSON string (OpenIAP 1.3.10+) + */ + private fun serializeStandardizedDiscountOffers(offers: List): String { + val array = JSONArray() + offers.forEach { offer -> + val offerJson = JSONObject() + offerJson.put("currency", offer.currency) + offerJson.put("displayPrice", offer.displayPrice) + offerJson.put("price", offer.price) + offer.id?.let { offerJson.put("id", it) } + offer.offerTagsAndroid?.let { offerJson.put("offerTagsAndroid", JSONArray(it)) } + offer.offerTokenAndroid?.let { offerJson.put("offerTokenAndroid", it) } + offer.discountAmountMicrosAndroid?.let { offerJson.put("discountAmountMicrosAndroid", it) } + offer.formattedDiscountAmountAndroid?.let { offerJson.put("formattedDiscountAmountAndroid", it) } + offer.fullPriceMicrosAndroid?.let { offerJson.put("fullPriceMicrosAndroid", it) } + offer.limitedQuantityInfoAndroid?.let { info -> + val infoJson = JSONObject() + infoJson.put("maximumQuantity", info.maximumQuantity) + infoJson.put("remainingQuantity", info.remainingQuantity) + offerJson.put("limitedQuantityInfoAndroid", infoJson) + } + offer.validTimeWindowAndroid?.let { window -> + val windowJson = JSONObject() + windowJson.put("startTimeMillis", window.startTimeMillis) + windowJson.put("endTimeMillis", window.endTimeMillis) + offerJson.put("validTimeWindowAndroid", windowJson) + } + array.put(offerJson) + } + return array.toString() + } + private fun convertToNitroProduct(product: ProductCommon): NitroProduct { val subscriptionOffers = when (product) { is ProductSubscriptionAndroid -> product.subscriptionOfferDetailsAndroid.orEmpty() @@ -940,6 +1018,26 @@ class HybridRnIap : HybridRnIapSpec() { else -> null } + // Serialize standardized cross-platform subscriptionOffers (OpenIAP 1.3.10+) + val standardizedSubsOffers = when (product) { + is ProductSubscriptionAndroid -> product.subscriptionOffers + is ProductAndroid -> product.subscriptionOffers + else -> null + } + val subscriptionOffersStandardizedJson = standardizedSubsOffers?.takeIf { it.isNotEmpty() }?.let { + serializeStandardizedSubscriptionOffers(it) + } + + // Serialize standardized cross-platform discountOffers (OpenIAP 1.3.10+) + val standardizedDiscountOffers = when (product) { + is ProductSubscriptionAndroid -> product.discountOffers + is ProductAndroid -> product.discountOffers + else -> null + } + val discountOffersJson = standardizedDiscountOffers?.takeIf { it.isNotEmpty() }?.let { + serializeStandardizedDiscountOffers(it) + } + return NitroProduct( id = product.id, title = product.title, @@ -961,6 +1059,8 @@ class HybridRnIap : HybridRnIapSpec() { introductoryPricePaymentModeIOS = PaymentModeIOS.EMPTY, introductoryPriceNumberOfPeriodsIOS = null, introductoryPriceSubscriptionPeriodIOS = null, + subscriptionOffers = subscriptionOffersStandardizedJson, + discountOffers = discountOffersJson, nameAndroid = nameAndroid, originalPriceAndroid = originalPriceAndroid, originalPriceAmountMicrosAndroid = originalPriceAmountMicrosAndroid, diff --git a/docs/docs/api/types.md b/docs/docs/api/types.md index 7410d5b0c..931d53c6d 100644 --- a/docs/docs/api/types.md +++ b/docs/docs/api/types.md @@ -137,6 +137,120 @@ export interface RentalDetailsAndroid { } ``` +### Cross-Platform Offer Types (v14.8.0+) + +Starting from v14.8.0, products include **standardized cross-platform offer types** that work consistently across iOS and Android. These new types replace the platform-specific `subscriptionInfoIOS` and `subscriptionOfferDetailsAndroid` with unified structures. + +#### SubscriptionOffer + +The `subscriptionOffers` field is available on both `ProductIOS` and `ProductAndroid` for subscription products: + +```ts +export interface SubscriptionOffer { + /** Unique identifier for the offer */ + id: string; + /** Formatted display price (e.g., "$9.99/month" or "Free") */ + displayPrice: string; + /** Numeric price value */ + price: number; + /** Type of offer: 'introductory' or 'promotional' */ + type: DiscountOfferType; + /** Currency code (ISO 4217, e.g., "USD") */ + currency?: string | null; + /** Payment mode during the offer period */ + paymentMode?: PaymentMode | null; + /** Subscription period for this offer */ + period?: SubscriptionPeriod | null; + /** Number of periods the offer applies */ + periodCount?: number | null; + // iOS-specific fields + /** [iOS] Key identifier for signature validation */ + keyIdentifierIOS?: string | null; + /** [iOS] Number of billing periods for this discount */ + numberOfPeriodsIOS?: number | null; + // Android-specific fields + /** [Android] Base plan identifier */ + basePlanIdAndroid?: string | null; + /** [Android] Offer token required for purchase */ + offerTokenAndroid?: string | null; + /** [Android] List of tags associated with this offer */ + offerTagsAndroid?: string[] | null; + /** [Android] Pricing phases for this subscription offer */ + pricingPhasesAndroid?: PricingPhasesAndroid | null; +} + +export type DiscountOfferType = 'introductory' | 'promotional'; +export type PaymentMode = 'free-trial' | 'pay-as-you-go' | 'pay-up-front'; + +export interface SubscriptionPeriod { + unit: SubscriptionPeriodUnit; + value: number; +} + +export type SubscriptionPeriodUnit = 'day' | 'week' | 'month' | 'year' | 'unknown'; +``` + +#### DiscountOffer + +The `discountOffers` field is available on both platforms for one-time purchase products with discounts: + +```ts +export interface DiscountOffer { + /** Currency code (ISO 4217, e.g., "USD") */ + currency: string; + /** Formatted display price (e.g., "$4.99") */ + displayPrice: string; + /** Numeric price value */ + price: number; + /** Unique identifier for the offer (Android only) */ + id?: string | null; + // Android-specific fields + /** [Android] Fixed discount amount in micro-units */ + discountAmountMicrosAndroid?: string | null; + /** [Android] Formatted discount amount (e.g., "$5.00 OFF") */ + formattedDiscountAmountAndroid?: string | null; + /** [Android] Original full price in micro-units before discount */ + fullPriceMicrosAndroid?: string | null; + /** [Android] Offer token required for purchase */ + offerTokenAndroid?: string | null; + /** [Android] List of tags associated with this offer */ + offerTagsAndroid?: string[] | null; + /** [Android] Limited quantity information */ + limitedQuantityInfoAndroid?: LimitedQuantityInfoAndroid | null; + /** [Android] Time window when the offer is valid */ + validTimeWindowAndroid?: ValidTimeWindowAndroid | null; +} +``` + +#### Usage Example + +```tsx +import {fetchProducts} from 'react-native-iap'; + +const products = await fetchProducts({skus: ['premium_monthly']}); + +// Access cross-platform subscription offers +products.forEach(product => { + if (product.subscriptionOffers) { + product.subscriptionOffers.forEach(offer => { + console.log(`Offer: ${offer.id}`); + console.log(`Type: ${offer.type}`); // 'introductory' or 'promotional' + console.log(`Price: ${offer.displayPrice}`); + console.log(`Payment Mode: ${offer.paymentMode}`); // 'free-trial', etc. + + // Platform-specific details + if (offer.offerTokenAndroid) { + console.log(`Android Token: ${offer.offerTokenAndroid}`); + } + }); + } +}); +``` + +:::tip Deprecation Notice +The platform-specific fields `subscriptionInfoIOS` and `subscriptionOfferDetailsAndroid` are now deprecated. Use the unified `subscriptionOffers` and `discountOffers` fields for new implementations. +::: + ## Purchase Types Purchases share the `PurchaseCommon` shape and discriminate on the same `platform` union. Both variants expose the unified `purchaseToken` field for server validation. diff --git a/docs/static/llms-full.txt b/docs/static/llms-full.txt index 5aded994c..e6f2d18aa 100644 --- a/docs/static/llms-full.txt +++ b/docs/static/llms-full.txt @@ -443,18 +443,76 @@ interface ProductIOS extends ProductCommon { isFamilyShareableIOS: boolean; jsonRepresentationIOS: string; typeIOS: 'consumable' | 'non-consumable' | 'auto-renewable-subscription' | 'non-renewing-subscription'; - subscriptionInfoIOS?: SubscriptionInfoIOS; + subscriptionInfoIOS?: SubscriptionInfoIOS; // @deprecated - use subscriptionOffers + // Cross-platform standardized offers (v14.8.0+) + subscriptionOffers?: SubscriptionOffer[]; + discountOffers?: DiscountOffer[]; } interface ProductAndroid extends ProductCommon { nameAndroid: string; oneTimePurchaseOfferDetailsAndroid?: ProductAndroidOneTimePurchaseOfferDetail[]; subscriptionOfferDetailsAndroid?: ProductSubscriptionAndroidOfferDetails[]; + // Cross-platform standardized offers (v14.8.0+) + subscriptionOffers?: SubscriptionOffer[]; + discountOffers?: DiscountOffer[]; } type Product = ProductIOS | ProductAndroid; ``` +### SubscriptionOffer (v14.8.0+) + +Cross-platform subscription offer type that unifies iOS and Android offer structures: + +```tsx +interface SubscriptionOffer { + id: string; // Unique offer identifier + displayPrice: string; // Formatted price (e.g., "$4.99/mo" or "Free") + price: number; // Numeric price value + type: 'introductory' | 'promotional'; // Offer type + currency?: string; // ISO 4217 currency code + paymentMode?: 'free-trial' | 'pay-as-you-go' | 'pay-up-front'; + period?: SubscriptionPeriod; // Duration of one billing period + periodCount?: number; // Number of periods the offer applies + // iOS-specific + keyIdentifierIOS?: string; // For signature validation + numberOfPeriodsIOS?: number; // Number of billing periods + localizedPriceIOS?: string; // Localized price string + // Android-specific + basePlanIdAndroid?: string; // Base plan identifier + offerTokenAndroid?: string; // Required for Android purchases + offerTagsAndroid?: string[]; // Tags for offer filtering + pricingPhasesAndroid?: PricingPhasesAndroid; // Detailed pricing phases +} + +interface SubscriptionPeriod { + unit: 'day' | 'week' | 'month' | 'year' | 'unknown'; + value: number; +} +``` + +### DiscountOffer (v14.8.0+) + +Cross-platform discount offer for one-time purchases: + +```tsx +interface DiscountOffer { + currency: string; // ISO 4217 currency code + displayPrice: string; // Formatted discounted price + price: number; // Numeric price value + id?: string; // Offer identifier (Android) + // Android-specific + offerTokenAndroid?: string; // Required for purchasing with discount + offerTagsAndroid?: string[]; // Tags for offer filtering + discountAmountMicrosAndroid?: string; // Discount in micros + formattedDiscountAmountAndroid?: string; // e.g., "$5.00 OFF" + fullPriceMicrosAndroid?: string; // Original price before discount + limitedQuantityInfoAndroid?: LimitedQuantityInfoAndroid; + validTimeWindowAndroid?: ValidTimeWindowAndroid; +} +``` + ### ProductSubscription ```tsx diff --git a/docs/static/llms.txt b/docs/static/llms.txt index b3ca2471c..f315fd708 100644 --- a/docs/static/llms.txt +++ b/docs/static/llms.txt @@ -163,15 +163,57 @@ interface Product { currency: string; type: 'in-app' | 'subs'; store: 'apple' | 'google' | 'horizon'; - // iOS specific + // Cross-platform standardized offers (v14.8.0+) + subscriptionOffers?: SubscriptionOffer[]; // For subscriptions + discountOffers?: DiscountOffer[]; // For one-time purchases + // iOS specific (deprecated - use subscriptionOffers) displayNameIOS?: string; isFamilyShareableIOS?: boolean; subscriptionInfoIOS?: SubscriptionInfoIOS; - // Android specific + // Android specific (deprecated - use subscriptionOffers) subscriptionOfferDetailsAndroid?: SubscriptionOfferDetails[]; } ``` +### SubscriptionOffer (v14.8.0+) + +Cross-platform subscription offer type: + +```tsx +interface SubscriptionOffer { + id: string; // Unique offer identifier + displayPrice: string; // Formatted price (e.g., "$4.99/mo" or "Free") + price: number; // Numeric price + type: 'introductory' | 'promotional'; + paymentMode?: 'free-trial' | 'pay-as-you-go' | 'pay-up-front'; + period?: { unit: 'day' | 'week' | 'month' | 'year'; value: number }; + periodCount?: number; + // Android specific + basePlanIdAndroid?: string; + offerTokenAndroid?: string; // Required for Android purchases + // iOS specific + keyIdentifierIOS?: string; + numberOfPeriodsIOS?: number; +} +``` + +### DiscountOffer (v14.8.0+) + +Cross-platform discount offer for one-time purchases: + +```tsx +interface DiscountOffer { + currency: string; + displayPrice: string; + price: number; + id?: string; + // Android specific + offerTokenAndroid?: string; + discountAmountMicrosAndroid?: string; + formattedDiscountAmountAndroid?: string; +} +``` + ### Purchase ```tsx diff --git a/example/screens/AllProducts.tsx b/example/screens/AllProducts.tsx index b879f9a6c..fbcb79b6d 100644 --- a/example/screens/AllProducts.tsx +++ b/example/screens/AllProducts.tsx @@ -443,6 +443,104 @@ function AllProducts() { )} )} + + {/* Cross-Platform Standardized Subscription Offers (v14.8.0+) */} + {'subscriptionOffers' in selectedProduct && + selectedProduct.subscriptionOffers && + selectedProduct.subscriptionOffers.length > 0 && ( + + + Cross-Platform Subscription Offers ( + {selectedProduct.subscriptionOffers.length}) + + + Standardized offers for both iOS and Android + + {selectedProduct.subscriptionOffers.map( + (offer, index: number) => ( + + + {offer.id || `Offer ${index + 1}`} + + + + Type: + + {offer.type} + + + + + Price: + + {offer.displayPrice} + + + + {offer.paymentMode && ( + + + Payment Mode: + + + {offer.paymentMode} + + + )} + + {offer.period && ( + + Period: + + {offer.period.value} {offer.period.unit} + {offer.periodCount + ? ` × ${offer.periodCount}` + : ''} + + + )} + + {/* Android-specific fields */} + {offer.basePlanIdAndroid && ( + + + Base Plan (Android): + + + {offer.basePlanIdAndroid} + + + )} + + {offer.offerTokenAndroid && ( + <> + + Offer Token (Android): + + + {offer.offerTokenAndroid} + + + )} + + ), + )} + + )} )} @@ -698,6 +796,31 @@ const styles = StyleSheet.create({ color: '#999', fontFamily: 'monospace', }, + standardizedOffer: { + borderLeftWidth: 3, + borderLeftColor: '#4CAF50', + }, + offerRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 4, + }, + offerBadge: { + backgroundColor: '#e8f5e9', + color: '#2e7d32', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 4, + fontSize: 11, + fontWeight: '600', + overflow: 'hidden', + }, + offersSectionSubtitle: { + fontSize: 11, + color: '#888', + marginBottom: 8, + fontStyle: 'italic', + }, pricingPhase: { backgroundColor: '#e3f2fd', borderRadius: 4, diff --git a/ios/RnIapHelper.swift b/ios/RnIapHelper.swift index cdbfddf90..6f6eef7fc 100644 --- a/ios/RnIapHelper.swift +++ b/ios/RnIapHelper.swift @@ -124,6 +124,30 @@ enum RnIapHelper { if let introductoryPeriod = dictionary["introductoryPriceSubscriptionPeriodIOS"] as? String { product.introductoryPriceSubscriptionPeriodIOS = introductoryPeriod } if let displayNameIOS = dictionary["displayNameIOS"] as? String { product.displayName = displayNameIOS } + // Handle subscriptionOffers - standardized cross-platform offers (OpenIAP 1.3.10+) + if let offersArray = dictionary["subscriptionOffers"] as? [[String: Any]], !offersArray.isEmpty { + do { + let jsonData = try JSONSerialization.data(withJSONObject: offersArray, options: []) + if let jsonString = String(data: jsonData, encoding: .utf8) { + product.subscriptionOffers = jsonString + } + } catch { + NSLog("⚠️ [RnIapHelper] Failed to serialize subscriptionOffers: \(error)") + } + } + + // Handle discountOffers - standardized cross-platform offers for one-time purchases (OpenIAP 1.3.10+) + if let discountOffersArray = dictionary["discountOffers"] as? [[String: Any]], !discountOffersArray.isEmpty { + do { + let jsonData = try JSONSerialization.data(withJSONObject: discountOffersArray, options: []) + if let jsonString = String(data: jsonData, encoding: .utf8) { + product.discountOffers = jsonString + } + } catch { + NSLog("⚠️ [RnIapHelper] Failed to serialize discountOffers: \(error)") + } + } + return product } diff --git a/src/__tests__/utils/type-bridge.test.ts b/src/__tests__/utils/type-bridge.test.ts index 120d3462d..476398335 100644 --- a/src/__tests__/utils/type-bridge.test.ts +++ b/src/__tests__/utils/type-bridge.test.ts @@ -101,6 +101,179 @@ describe('type-bridge utilities', () => { 'token', ); }); + + it('converts iOS subscription with standardized subscriptionOffers', () => { + const nitroProduct: NitroProduct = { + id: 'com.example.ios.subs', + title: 'Premium', + description: 'Premium subscription', + type: 'subs', + displayName: 'Premium Display', + displayPrice: '$4.99', + currency: 'USD', + price: 4.99, + platform: 'ios', + typeIOS: 'autoRenewableSubscription', + subscriptionOffers: JSON.stringify([ + { + id: 'intro_weekly', + displayPrice: 'Free', + price: 0, + type: 'introductory', + paymentMode: 'free-trial', + periodCount: 1, + period: { + unit: 'week', + value: 1, + }, + }, + { + id: 'promo_20off', + displayPrice: '$3.99', + price: 3.99, + type: 'promotional', + paymentMode: 'pay-as-you-go', + periodCount: 3, + period: { + unit: 'month', + value: 1, + }, + }, + ]), + } as NitroProduct; + + const result = convertNitroProductToProduct(nitroProduct) as any; + + expect(result.type).toBe('subs'); + expect(result.platform).toBe('ios'); + expect(Array.isArray(result.subscriptionOffers)).toBe(true); + expect(result.subscriptionOffers.length).toBe(2); + expect(result.subscriptionOffers[0].id).toBe('intro_weekly'); + expect(result.subscriptionOffers[0].type).toBe('introductory'); + expect(result.subscriptionOffers[0].paymentMode).toBe('free-trial'); + expect(result.subscriptionOffers[1].id).toBe('promo_20off'); + expect(result.subscriptionOffers[1].type).toBe('promotional'); + }); + + it('converts Android subscription with standardized subscriptionOffers', () => { + const nitroProduct: NitroProduct = { + id: 'com.example.android.subs', + title: 'Premium', + description: 'Premium subscription', + type: 'subs', + displayName: 'Premium Display', + displayPrice: '$4.99', + currency: 'USD', + price: 4.99, + platform: 'android', + subscriptionOffers: JSON.stringify([ + { + id: 'base-monthly', + displayPrice: '$4.99', + price: 4.99, + type: 'introductory', + basePlanIdAndroid: 'monthly', + offerTokenAndroid: 'token123', + offerTagsAndroid: ['monthly', 'default'], + paymentMode: 'pay-as-you-go', + period: { + unit: 'month', + value: 1, + }, + pricingPhasesAndroid: { + pricingPhaseList: [ + { + formattedPrice: '$4.99', + priceAmountMicros: '4990000', + priceCurrencyCode: 'USD', + billingPeriod: 'P1M', + billingCycleCount: 0, + recurrenceMode: 2, + }, + ], + }, + }, + ]), + } as NitroProduct; + + const result = convertNitroProductToProduct(nitroProduct) as any; + + expect(result.type).toBe('subs'); + expect(result.platform).toBe('android'); + expect(Array.isArray(result.subscriptionOffers)).toBe(true); + expect(result.subscriptionOffers[0].basePlanIdAndroid).toBe('monthly'); + expect(result.subscriptionOffers[0].offerTokenAndroid).toBe('token123'); + }); + + it('converts Android product with standardized discountOffers', () => { + const nitroProduct: NitroProduct = { + id: 'com.example.android.otp', + title: 'Item Pack', + description: 'One-time purchase item pack', + type: 'inapp', + displayName: 'Item Pack Display', + displayPrice: '$9.99', + currency: 'USD', + price: 9.99, + platform: 'android', + discountOffers: JSON.stringify([ + { + id: 'discount_50off', + currency: 'USD', + displayPrice: '$4.99', + price: 4.99, + offerTokenAndroid: 'discount_token123', + offerTagsAndroid: ['sale', 'limited'], + discountAmountMicrosAndroid: '5000000', + formattedDiscountAmountAndroid: '$5.00 OFF', + fullPriceMicrosAndroid: '9990000', + }, + ]), + } as NitroProduct; + + const result = convertNitroProductToProduct(nitroProduct) as any; + + expect(result.type).toBe('in-app'); + expect(result.platform).toBe('android'); + expect(Array.isArray(result.discountOffers)).toBe(true); + expect(result.discountOffers[0].id).toBe('discount_50off'); + expect(result.discountOffers[0].discountAmountMicrosAndroid).toBe( + '5000000', + ); + expect(result.discountOffers[0].formattedDiscountAmountAndroid).toBe( + '$5.00 OFF', + ); + }); + + it('handles missing subscriptionOffers gracefully', () => { + const nitroProduct: NitroProduct = { + id: 'com.example.product', + title: 'Test Product', + description: 'Test Description', + type: 'subs', + platform: 'ios', + } as NitroProduct; + + const result = convertNitroProductToProduct(nitroProduct) as any; + + expect(result.subscriptionOffers).toBeNull(); + expect(result.discountOffers).toBeNull(); + }); + + it('handles invalid JSON in subscriptionOffers gracefully', () => { + const nitroProduct: NitroProduct = { + id: 'com.example.product', + title: 'Test Product', + description: 'Test Description', + type: 'subs', + platform: 'android', + subscriptionOffers: 'invalid json{', + } as NitroProduct; + + const result = convertNitroProductToProduct(nitroProduct) as any; + + expect(result.subscriptionOffers).toBeNull(); + }); }); describe('convertProductToProductSubscription', () => { diff --git a/src/specs/RnIap.nitro.ts b/src/specs/RnIap.nitro.ts index 68066ba6b..431c26b1b 100644 --- a/src/specs/RnIap.nitro.ts +++ b/src/specs/RnIap.nitro.ts @@ -520,6 +520,11 @@ export interface NitroProduct { introductoryPriceSubscriptionPeriodIOS?: string | null; subscriptionPeriodNumberIOS?: number | null; subscriptionPeriodUnitIOS?: string | null; + // Cross-platform standardized offer fields (JSON serialized) + /** Standardized subscription offers (JSON string) - cross-platform */ + subscriptionOffers?: string | null; + /** Standardized discount offers for one-time purchases (JSON string) - cross-platform */ + discountOffers?: string | null; // Android specific fields nameAndroid?: string | null; originalPriceAndroid?: string | null; diff --git a/src/utils/type-bridge.ts b/src/utils/type-bridge.ts index 449a2abe2..c0cf27187 100644 --- a/src/utils/type-bridge.ts +++ b/src/utils/type-bridge.ts @@ -277,6 +277,30 @@ export function convertNitroProductToProduct( iosProduct.discountsIOS = null; } + // Parse standardized subscriptionOffers (cross-platform, OpenIAP 1.3.10+) + if (nitroProduct.subscriptionOffers) { + try { + iosProduct.subscriptionOffers = JSON.parse( + nitroProduct.subscriptionOffers, + ); + } catch { + iosProduct.subscriptionOffers = null; + } + } else { + iosProduct.subscriptionOffers = null; + } + + // Parse standardized discountOffers (cross-platform, OpenIAP 1.3.10+) + if (nitroProduct.discountOffers) { + try { + iosProduct.discountOffers = JSON.parse(nitroProduct.discountOffers); + } catch { + iosProduct.discountOffers = null; + } + } else { + iosProduct.discountOffers = null; + } + return iosProduct as Product; } @@ -290,6 +314,30 @@ export function convertNitroProductToProduct( ), }; + // Parse standardized subscriptionOffers (cross-platform, OpenIAP 1.3.10+) + if (nitroProduct.subscriptionOffers) { + try { + androidProduct.subscriptionOffers = JSON.parse( + nitroProduct.subscriptionOffers, + ); + } catch { + androidProduct.subscriptionOffers = null; + } + } else { + androidProduct.subscriptionOffers = null; + } + + // Parse standardized discountOffers (cross-platform, OpenIAP 1.3.10+) + if (nitroProduct.discountOffers) { + try { + androidProduct.discountOffers = JSON.parse(nitroProduct.discountOffers); + } catch { + androidProduct.discountOffers = null; + } + } else { + androidProduct.discountOffers = null; + } + if (type === PRODUCT_TYPE_SUBS) { if (!Array.isArray(androidProduct.subscriptionOfferDetailsAndroid)) { androidProduct.subscriptionOfferDetailsAndroid = []; From 6eb3a6b17355edc84a1af9307a6e56d228415f56 Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 17 Jan 2026 09:02:22 +0900 Subject: [PATCH 3/7] feat(example): migrate to cross-platform subscriptionOffers and discountOffers - Update PurchaseFlow.tsx to use discountOffers instead of deprecated oneTimePurchaseOfferDetailsAndroid - Update SubscriptionFlow.tsx to use subscriptionOffers instead of deprecated subscriptionOfferDetailsAndroid - Update subscription-offers guide documentation with cross-platform types - Remove AndroidOneTimeOfferDetails component usage (deprecated) - Update PurchaseFlow.test.tsx to test cross-platform discountOffers This aligns with expo-iap PR #302 and OpenIAP spec for unified offer handling. Co-Authored-By: Claude Opus 4.5 --- docs/docs/guides/subscription-offers.md | 136 ++++++--- .../__tests__/screens/PurchaseFlow.test.tsx | 190 ++++++------ example/screens/PurchaseFlow.tsx | 133 ++++++++- example/screens/SubscriptionFlow.tsx | 274 +++++++++++------- 4 files changed, 474 insertions(+), 259 deletions(-) diff --git a/docs/docs/guides/subscription-offers.md b/docs/docs/guides/subscription-offers.md index a7e7e9709..83774e037 100644 --- a/docs/docs/guides/subscription-offers.md +++ b/docs/docs/guides/subscription-offers.md @@ -23,6 +23,51 @@ Subscription offers represent different pricing plans for the same subscription - **Introductory Offers**: Special pricing for new subscribers (free trial, discounted period) - **Promotional Offers**: Limited-time discounts configured in the app stores +## Cross-Platform Types (v14.8.0+) + +Starting with v14.8.0, react-native-iap provides cross-platform `SubscriptionOffer` and `DiscountOffer` types that unify iOS and Android offer handling: + +```tsx +interface SubscriptionOffer { + id: string; // Unique offer identifier + displayPrice: string; // Formatted price (e.g., "$4.99/mo" or "Free") + price: number; // Numeric price + type: 'introductory' | 'promotional'; + paymentMode?: 'free-trial' | 'pay-as-you-go' | 'pay-up-front'; + period?: { unit: 'day' | 'week' | 'month' | 'year'; value: number }; + periodCount?: number; + // Android specific + basePlanIdAndroid?: string; + offerTokenAndroid?: string; // Required for Android purchases + pricingPhasesAndroid?: PricingPhasesAndroid; + offerTagsAndroid?: string[]; + // iOS specific + keyIdentifierIOS?: string; + numberOfPeriodsIOS?: number; +} + +interface DiscountOffer { + currency: string; + displayPrice: string; + price: number; + id?: string; + // Android specific + offerTokenAndroid?: string; + discountAmountMicrosAndroid?: string; + formattedDiscountAmountAndroid?: string; + fullPriceMicrosAndroid?: string; + validTimeWindowAndroid?: ValidTimeWindowAndroid; + limitedQuantityInfoAndroid?: LimitedQuantityInfoAndroid; +} +``` + +:::note Deprecated Types +The following types are deprecated in favor of the cross-platform types above: +- `subscriptionOfferDetailsAndroid` → Use `subscriptionOffers` +- `oneTimePurchaseOfferDetailsAndroid` → Use `discountOffers` +- `subscriptionInfoIOS` → Use `subscriptionOffers` +::: + ## Platform Differences At a glance: @@ -59,16 +104,14 @@ const SubscriptionComponent = () => { } }, [connected]); - // 2) Access offer details from fetched subscriptions + // 2) Access offer details from fetched subscriptions (cross-platform) const subscription = subscriptions.find((s) => s.id === 'premium_monthly'); - if (subscription?.subscriptionOfferDetailsAndroid) { - console.log( - 'Available offers:', - subscription.subscriptionOfferDetailsAndroid, - ); - // Each offer contains: basePlanId*, offerId?, offerTags, offerToken, pricingPhases - // *Note: basePlanId has limitations - see "Android basePlanId Limitation" section below + if (subscription?.subscriptionOffers) { + console.log('Available offers:', subscription.subscriptionOffers); + // Each offer contains: id, displayPrice, price, type, paymentMode, period + // Android specific: basePlanIdAndroid, offerTokenAndroid, pricingPhasesAndroid + // iOS specific: keyIdentifierIOS, numberOfPeriodsIOS } }; ``` @@ -80,15 +123,13 @@ const purchaseSubscription = async (subscriptionId: string) => { const subscription = subscriptions.find((s) => s.id === subscriptionId); if (!subscription) return; - // Build subscriptionOffers from fetched data with proper filtering - const subscriptionOffers = ( - subscription.subscriptionOfferDetailsAndroid ?? [] - ) + // Build subscriptionOffers from cross-platform data with proper filtering + const subscriptionOffers = (subscription.subscriptionOffers ?? []) .map((offer) => - offer?.offerToken + offer?.offerTokenAndroid ? { sku: subscriptionId, - offerToken: offer.offerToken, + offerToken: offer.offerTokenAndroid, } : null, ) @@ -117,23 +158,29 @@ const purchaseSubscription = async (subscriptionId: string) => { #### Understanding Offer Details -Each `subscriptionOfferDetailsAndroid` item contains: +Each `subscriptionOffers` item (cross-platform `SubscriptionOffer`) contains: ```tsx -interface ProductSubscriptionAndroidOfferDetails { - basePlanId: string; // Base plan identifier (⚠️ see limitation below) - offerId?: string | null; // Offer identifier (null for base plan) - offerTags: string[]; // Tags associated with the offer - offerToken: string; // Token required for purchase - pricingPhases: PricingPhasesAndroid; // Pricing information +interface SubscriptionOffer { + id: string; // Unique offer identifier + displayPrice: string; // Formatted price display (e.g., "$4.99/mo") + price: number; // Numeric price value + type: 'introductory' | 'promotional'; + paymentMode?: 'free-trial' | 'pay-as-you-go' | 'pay-up-front'; + period?: { unit: 'day' | 'week' | 'month' | 'year'; value: number }; + // Android specific + basePlanIdAndroid?: string; // Base plan identifier (⚠️ see limitation below) + offerTokenAndroid?: string; // Token required for purchase + pricingPhasesAndroid?: PricingPhasesAndroid; // Pricing phases info + offerTagsAndroid?: string[]; // Tags associated with the offer } ``` -#### Android `basePlanId` Limitation {#baseplanid-limitation} +#### Android `basePlanIdAndroid` Limitation {#baseplanid-limitation} :::caution Client-Side Limitation -The `basePlanId` is available when fetching products, but **not** when retrieving purchases via `getAvailablePurchases()`. This is a limitation of Google Play Billing Library - the purchase token alone doesn't reveal which base plan was purchased. +The `basePlanIdAndroid` is available when fetching products, but **not** when retrieving purchases via `getAvailablePurchases()`. This is a limitation of Google Play Billing Library - the purchase token alone doesn't reveal which base plan was purchased. See [GitHub Issue #3096](https://github.com/hyochan/react-native-iap/issues/3096) for more details. @@ -142,7 +189,7 @@ See [GitHub Issue #3096](https://github.com/hyochan/react-native-iap/issues/3096 **Why this matters:** - If you have multiple base plans (e.g., `monthly`, `yearly`, `premium`), you cannot determine which plan the user is subscribed to using client-side APIs alone -- The `basePlanId` is only available from `subscriptionOfferDetailsAndroid` at the time of purchase, not from restored purchases +- The `basePlanIdAndroid` is only available from `subscriptionOffers` at the time of purchase, not from restored purchases **Solution: Server-Side Verification with IAPKit** @@ -166,6 +213,24 @@ const verifyAndroidSubscription = async (purchase: Purchase) => { The server response includes `offerDetails.basePlanId` in the `lineItems` array, allowing you to identify exactly which subscription plan the user purchased. +#### Discount Offers for One-Time Products (Android) + +Starting with v14.8.0, Android one-time products can have discount offers via the `discountOffers` field: + +```tsx +const product = products.find((p) => p.id === 'premium_unlock'); + +if (product?.discountOffers && product.discountOffers.length > 0) { + product.discountOffers.forEach((offer) => { + console.log('Discount offer:', offer.id); + console.log('Display price:', offer.displayPrice); + console.log('Full price (micros):', offer.fullPriceMicrosAndroid); + console.log('Discount amount:', offer.formattedDiscountAmountAndroid); + console.log('Percentage off:', offer.percentageDiscountAndroid); + }); +} +``` + See IAPKit documentation for setup instructions and API details. ### iOS Subscription Offers @@ -393,24 +458,15 @@ const selectOffer = ( subscription: ProductSubscription, offerType: 'base' | 'introductory', ) => { - if (Platform.OS === 'ios') { - // iOS: Return introductory offer if requested and available - if (offerType === 'introductory') { - return subscription.subscriptionInfoIOS?.introductoryOffer ?? null; - } - // Base plan doesn't need explicit selection - return null; - } - - // Android: Select offer based on type - const offers = subscription.subscriptionOfferDetailsAndroid ?? []; + // Use cross-platform subscriptionOffers + const offers = subscription.subscriptionOffers ?? []; if (offerType === 'base') { - // Find base plan (no offerId) - return offers.find((offer) => !offer.offerId); + // Find base plan (type is not introductory/promotional) + return offers.find((offer) => offer.type !== 'introductory' && offer.type !== 'promotional'); } else { // Find introductory offer - return offers.find((offer) => offer.offerId?.includes('introductory')); + return offers.find((offer) => offer.type === 'introductory'); } }; @@ -424,11 +480,11 @@ const purchaseWithSelectedOffer = async ( const selectedOffer = selectOffer(subscription, offerType); if (Platform.OS === 'android') { - const subscriptionOffers = selectedOffer + const subscriptionOffers = selectedOffer?.offerTokenAndroid ? [ { sku: subscriptionId, - offerToken: selectedOffer.offerToken, + offerToken: selectedOffer.offerTokenAndroid, }, ] : []; diff --git a/example/__tests__/screens/PurchaseFlow.test.tsx b/example/__tests__/screens/PurchaseFlow.test.tsx index 61a1d8d7f..eebc1dd52 100644 --- a/example/__tests__/screens/PurchaseFlow.test.tsx +++ b/example/__tests__/screens/PurchaseFlow.test.tsx @@ -30,7 +30,7 @@ describe('PurchaseFlow Screen', () => { }, ]; - // Android product with one-time purchase offers (discount support) + // Android product with discount offers (cross-platform) const androidProductWithOffers = { id: 'dev.hyo.martie.10bulbs', title: '10 Bulbs', @@ -41,41 +41,41 @@ describe('PurchaseFlow Screen', () => { type: 'in-app' as const, platform: 'android' as const, nameAndroid: '10 Bulbs', - oneTimePurchaseOfferDetailsAndroid: [ + discountOffers: [ { - formattedPrice: '$0.99', - priceAmountMicros: '990000', - priceCurrencyCode: 'USD', - offerId: 'base-offer', - offerToken: 'token-base-123', - offerTags: ['bestseller'], - fullPriceMicros: null, - discountDisplayInfo: null, - limitedQuantityInfo: null, - validTimeWindow: null, + id: 'base-offer', + displayPrice: '$0.99', + price: 0.99, + currency: 'USD', + type: 'one-time' as const, + offerTokenAndroid: 'token-base-123', + offerTagsAndroid: ['bestseller'], + fullPriceMicrosAndroid: null, + percentageDiscountAndroid: null, + formattedDiscountAmountAndroid: null, + discountAmountMicrosAndroid: null, + limitedQuantityInfoAndroid: null, + validTimeWindowAndroid: null, preorderDetailsAndroid: null, rentalDetailsAndroid: null, }, { - formattedPrice: '$0.79', - priceAmountMicros: '790000', - priceCurrencyCode: 'USD', - offerId: 'discount-offer', - offerToken: 'token-discount-456', - offerTags: ['sale', 'limited'], - fullPriceMicros: '990000', - discountDisplayInfo: { - percentageDiscount: 20, - discountAmount: { - discountAmountMicros: '200000', - formattedDiscountAmount: '$0.20', - }, - }, - limitedQuantityInfo: { + id: 'discount-offer', + displayPrice: '$0.79', + price: 0.79, + currency: 'USD', + type: 'one-time' as const, + offerTokenAndroid: 'token-discount-456', + offerTagsAndroid: ['sale', 'limited'], + fullPriceMicrosAndroid: '990000', + percentageDiscountAndroid: 20, + formattedDiscountAmountAndroid: '$0.20', + discountAmountMicrosAndroid: '200000', + limitedQuantityInfoAndroid: { maximumQuantity: 100, remainingQuantity: 45, }, - validTimeWindow: { + validTimeWindowAndroid: { startTimeMillis: '1702300800000', endTimeMillis: '1702905600000', }, @@ -96,18 +96,21 @@ describe('PurchaseFlow Screen', () => { type: 'in-app' as const, platform: 'android' as const, nameAndroid: 'Preorder Item', - oneTimePurchaseOfferDetailsAndroid: [ + discountOffers: [ { - formattedPrice: '$9.99', - priceAmountMicros: '9990000', - priceCurrencyCode: 'USD', - offerId: null, - offerToken: 'token-preorder-789', - offerTags: [], - fullPriceMicros: null, - discountDisplayInfo: null, - limitedQuantityInfo: null, - validTimeWindow: null, + id: null, + displayPrice: '$9.99', + price: 9.99, + currency: 'USD', + type: 'one-time' as const, + offerTokenAndroid: 'token-preorder-789', + offerTagsAndroid: [], + fullPriceMicrosAndroid: null, + percentageDiscountAndroid: null, + formattedDiscountAmountAndroid: null, + discountAmountMicrosAndroid: null, + limitedQuantityInfoAndroid: null, + validTimeWindowAndroid: null, preorderDetailsAndroid: { preorderReleaseTimeMillis: '1704067200000', preorderPresaleEndTimeMillis: '1703980800000', @@ -128,18 +131,21 @@ describe('PurchaseFlow Screen', () => { type: 'in-app' as const, platform: 'android' as const, nameAndroid: 'Rental Item', - oneTimePurchaseOfferDetailsAndroid: [ + discountOffers: [ { - formattedPrice: '$4.99', - priceAmountMicros: '4990000', - priceCurrencyCode: 'USD', - offerId: null, - offerToken: 'token-rental-abc', - offerTags: [], - fullPriceMicros: null, - discountDisplayInfo: null, - limitedQuantityInfo: null, - validTimeWindow: null, + id: null, + displayPrice: '$4.99', + price: 4.99, + currency: 'USD', + type: 'one-time' as const, + offerTokenAndroid: 'token-rental-abc', + offerTagsAndroid: [], + fullPriceMicrosAndroid: null, + percentageDiscountAndroid: null, + formattedDiscountAmountAndroid: null, + discountAmountMicrosAndroid: null, + limitedQuantityInfoAndroid: null, + validTimeWindowAndroid: null, preorderDetailsAndroid: null, rentalDetailsAndroid: { rentalPeriod: 'P7D', @@ -160,24 +166,21 @@ describe('PurchaseFlow Screen', () => { type: 'in-app' as const, platform: 'android' as const, nameAndroid: 'Absolute Discount Item', - oneTimePurchaseOfferDetailsAndroid: [ + discountOffers: [ { - formattedPrice: '$1.99', - priceAmountMicros: '1990000', - priceCurrencyCode: 'USD', - offerId: 'abs-discount', - offerToken: 'token-abs-xyz', - offerTags: [], - fullPriceMicros: '2990000', - discountDisplayInfo: { - percentageDiscount: null, - discountAmount: { - discountAmountMicros: '1000000', - formattedDiscountAmount: '$1.00', - }, - }, - limitedQuantityInfo: null, - validTimeWindow: null, + id: 'abs-discount', + displayPrice: '$1.99', + price: 1.99, + currency: 'USD', + type: 'one-time' as const, + offerTokenAndroid: 'token-abs-xyz', + offerTagsAndroid: [], + fullPriceMicrosAndroid: '2990000', + percentageDiscountAndroid: null, + formattedDiscountAmountAndroid: '$1.00', + discountAmountMicrosAndroid: '1000000', + limitedQuantityInfoAndroid: null, + validTimeWindowAndroid: null, preorderDetailsAndroid: null, rentalDetailsAndroid: null, }, @@ -332,9 +335,9 @@ describe('PurchaseFlow Screen', () => { }); }); - // Android one-time purchase offers tests - describe('Android One-Time Purchase Offers', () => { - it('displays product details modal with Android offers', async () => { + // Android discount offers tests (cross-platform) + describe('Android Discount Offers (Cross-platform)', () => { + it('displays product details modal with discount offers', async () => { mockIapState({products: [androidProductWithOffers]}); const {getByText, getAllByText} = render(); @@ -345,7 +348,7 @@ describe('PurchaseFlow Screen', () => { await waitFor(() => { expect(getByText('Product Details')).toBeTruthy(); - expect(getByText('One-Time Purchase Offers (2)')).toBeTruthy(); + expect(getByText('Discount Offers (2)')).toBeTruthy(); }); }); @@ -373,8 +376,8 @@ describe('PurchaseFlow Screen', () => { fireEvent.press(detailsButton!); await waitFor(() => { - expect(getByText('Full Price:')).toBeTruthy(); - expect(getByText('990000 micros')).toBeTruthy(); + expect(getByText('Full Price (micros):')).toBeTruthy(); + expect(getByText('990000')).toBeTruthy(); }); }); @@ -447,11 +450,11 @@ describe('PurchaseFlow Screen', () => { await waitFor(() => { expect(getByText('Rental:')).toBeTruthy(); - expect(getByText('Period: P7D')).toBeTruthy(); + expect(getByText('Period: P30D')).toBeTruthy(); }); }); - it('displays absolute discount amount when no percentage', async () => { + it('displays formatted discount amount', async () => { mockIapState({products: [androidProductWithAbsoluteDiscount]}); const {getByText, getAllByText} = render(); @@ -461,7 +464,7 @@ describe('PurchaseFlow Screen', () => { fireEvent.press(detailsButton!); await waitFor(() => { - expect(getByText('$1.00 off')).toBeTruthy(); + expect(getByText('$1.00')).toBeTruthy(); }); }); @@ -482,7 +485,7 @@ describe('PurchaseFlow Screen', () => { fireEvent.press(getByText('Close')); await waitFor(() => { - expect(queryByText('One-Time Purchase Offers (2)')).toBeNull(); + expect(queryByText('Discount Offers (2)')).toBeNull(); }); }); @@ -496,37 +499,8 @@ describe('PurchaseFlow Screen', () => { fireEvent.press(detailsButton!); await waitFor(() => { - expect(getByText('Offer 1 (base-offer)')).toBeTruthy(); - expect(getByText('Offer 2 (discount-offer)')).toBeTruthy(); - }); - }); - - it('displays offer token', async () => { - mockIapState({products: [androidProductWithOffers]}); - - const {getByText, getAllByText} = render(); - - // Open modal - const detailsButton = getAllByText('Details')[0]; - fireEvent.press(detailsButton!); - - await waitFor(() => { - expect(getAllByText('Offer Token:').length).toBeGreaterThanOrEqual(1); - expect(getByText('token-base-123')).toBeTruthy(); - }); - }); - - it('displays price with micros', async () => { - mockIapState({products: [androidProductWithOffers]}); - - const {getByText, getAllByText} = render(); - - // Open modal - const detailsButton = getAllByText('Details')[0]; - fireEvent.press(detailsButton!); - - await waitFor(() => { - expect(getByText('$0.99 (990000 micros)')).toBeTruthy(); + expect(getByText('base-offer')).toBeTruthy(); + expect(getByText('discount-offer')).toBeTruthy(); }); }); }); diff --git a/example/screens/PurchaseFlow.tsx b/example/screens/PurchaseFlow.tsx index fa953d6d2..1d8fce2a3 100644 --- a/example/screens/PurchaseFlow.tsx +++ b/example/screens/PurchaseFlow.tsx @@ -31,13 +31,11 @@ import { } from '../src/hooks/useVerificationMethod'; import type { Product, - ProductAndroid, Purchase, PurchaseError, VerifyPurchaseWithProviderProps, } from 'react-native-iap'; import PurchaseSummaryRow from '../src/components/PurchaseSummaryRow'; -import AndroidOneTimeOfferDetails from '../src/components/AndroidOneTimeOfferDetails'; const CONSUMABLE_PRODUCT_ID_SET = new Set(CONSUMABLE_PRODUCT_IDS); const NON_CONSUMABLE_PRODUCT_ID_SET = new Set(NON_CONSUMABLE_PRODUCT_IDS); @@ -386,15 +384,128 @@ function PurchaseFlow({ )} - {/* Android One-Time Purchase Offers */} - {selectedProduct.platform === 'android' && - 'oneTimePurchaseOfferDetailsAndroid' in selectedProduct && ( - + {/* Discount Offers (Cross-platform) */} + {'discountOffers' in selectedProduct && + selectedProduct.discountOffers && + Array.isArray(selectedProduct.discountOffers) && + selectedProduct.discountOffers.length > 0 && ( + + + Discount Offers ( + {selectedProduct.discountOffers.length}) + + {selectedProduct.discountOffers.map((offer, idx) => ( + + + {offer.id || `Offer ${idx + 1}`} + + Price: + + {offer.displayPrice} + + {offer.fullPriceMicrosAndroid && ( + <> + + Full Price (micros): + + + {offer.fullPriceMicrosAndroid} + + + )} + {offer.percentageDiscountAndroid && ( + + {offer.percentageDiscountAndroid}% off + + )} + {offer.formattedDiscountAmountAndroid && ( + <> + Discount: + + {offer.formattedDiscountAmountAndroid} + + + )} + {offer.validTimeWindowAndroid && ( + <> + + Valid Window: + + + {new Date( + Number( + offer.validTimeWindowAndroid + .startTimeMillis, + ), + ).toLocaleDateString()}{' '} + -{' '} + {new Date( + Number( + offer.validTimeWindowAndroid + .endTimeMillis, + ), + ).toLocaleDateString()} + + + )} + {offer.limitedQuantityInfoAndroid && ( + <> + + Limited Quantity: + + + { + offer.limitedQuantityInfoAndroid + .remainingQuantity + }{' '} + /{' '} + { + offer.limitedQuantityInfoAndroid + .maximumQuantity + }{' '} + remaining + + + )} + {offer.preorderDetailsAndroid && ( + <> + + Pre-order Release: + + + {new Date( + Number( + offer.preorderDetailsAndroid + .preorderReleaseTimeMillis, + ), + ).toLocaleDateString()} + + + )} + {offer.rentalDetailsAndroid && ( + <> + Rental: + + Period:{' '} + { + offer.rentalDetailsAndroid + .rentalExpirationPeriod + } + + + )} + {Array.isArray(offer.offerTagsAndroid) && + offer.offerTagsAndroid.length > 0 && ( + <> + Tags: + + {offer.offerTagsAndroid.join(', ')} + + + )} + + ))} + )} )} diff --git a/example/screens/SubscriptionFlow.tsx b/example/screens/SubscriptionFlow.tsx index 5a790917a..d526680d8 100644 --- a/example/screens/SubscriptionFlow.tsx +++ b/example/screens/SubscriptionFlow.tsx @@ -21,7 +21,7 @@ import { type Purchase, type PurchaseError, type VerifyPurchaseWithProviderProps, - type ProductSubscriptionAndroidOfferDetails, + type SubscriptionOffer, ErrorCode, } from 'react-native-iap'; import {IAPKIT_API_KEY} from '@env'; @@ -33,7 +33,6 @@ import { type VerificationMethod, } from '../src/hooks/useVerificationMethod'; import PurchaseSummaryRow from '../src/components/PurchaseSummaryRow'; -import AndroidOneTimeOfferDetails from '../src/components/AndroidOneTimeOfferDetails'; type ExtendedPurchase = Purchase & { purchaseTokenAndroid?: string; @@ -369,10 +368,9 @@ function SubscriptionFlow({ // Android subscription replacement const targetSubWithDetails = targetSubscription as ProductSubscriptionAndroid; - const androidOffers = - targetSubWithDetails.subscriptionOfferDetailsAndroid; + const androidOffers = targetSubWithDetails.subscriptionOffers; const targetOffer = androidOffers?.find( - (offer) => offer.basePlanId === targetBasePlanId, + (offer) => offer.basePlanIdAndroid === targetBasePlanId, ); if (!targetOffer) { @@ -418,15 +416,15 @@ function SubscriptionFlow({ skus: [targetProductId], currentBasePlanId, targetBasePlanId, - offerToken: targetOffer.offerToken, + offerToken: targetOffer.offerTokenAndroid, replacementMode, purchaseToken: tokenString ? `<${tokenString.substring(0, 10)}...>` : 'missing', allOffers: androidOffers?.map((o) => ({ - basePlanId: o.basePlanId, - offerId: o.offerId, - offerToken: o.offerToken?.substring(0, 20) + '...', + basePlanId: o.basePlanIdAndroid, + offerId: o.id, + offerToken: o.offerTokenAndroid?.substring(0, 20) + '...', })), }); @@ -438,7 +436,7 @@ function SubscriptionFlow({ subscriptionOffers: [ { sku: targetProductId, - offerToken: targetOffer.offerToken, + offerToken: targetOffer.offerTokenAndroid ?? '', }, ], replacementModeAndroid: replacementMode, @@ -489,18 +487,17 @@ function SubscriptionFlow({ const jsonString = JSON.stringify(selectedSubscription, null, 2); - // Check for Android offers + // Check for subscription offers (cross-platform) const hasSubscriptionOffers = - selectedSubscription.platform === 'android' && - 'subscriptionOfferDetailsAndroid' in selectedSubscription && - selectedSubscription.subscriptionOfferDetailsAndroid && - selectedSubscription.subscriptionOfferDetailsAndroid.length > 0; + 'subscriptionOffers' in selectedSubscription && + selectedSubscription.subscriptionOffers && + selectedSubscription.subscriptionOffers.length > 0; - const hasOneTimeOffers = - selectedSubscription.platform === 'android' && - 'oneTimePurchaseOfferDetailsAndroid' in selectedSubscription && - selectedSubscription.oneTimePurchaseOfferDetailsAndroid && - selectedSubscription.oneTimePurchaseOfferDetailsAndroid.length > 0; + // Check for discount offers (cross-platform) + const hasDiscountOffers = + 'discountOffers' in selectedSubscription && + selectedSubscription.discountOffers && + selectedSubscription.discountOffers.length > 0; return ( @@ -517,85 +514,155 @@ function SubscriptionFlow({ {selectedSubscription.displayPrice} - {/* Android Subscription Offers */} + {/* Subscription Offers (Cross-platform) */} {hasSubscriptionOffers && ( Subscription Offers ( - { - (selectedSubscription as ProductSubscriptionAndroid) - .subscriptionOfferDetailsAndroid.length - } - ) + {selectedSubscription.subscriptionOffers!.length}) - {( - selectedSubscription as ProductSubscriptionAndroid - ).subscriptionOfferDetailsAndroid.map( - ( - offer: ProductSubscriptionAndroidOfferDetails, - index: number, - ) => ( - + {selectedSubscription.subscriptionOffers!.map( + (offer: SubscriptionOffer, index: number) => ( + - Offer {index + 1} - {offer.offerId ? ` (${offer.offerId})` : ''} + {offer.id || `Offer ${index + 1}`} - Base Plan ID: - {offer.basePlanId} + Price: + {offer.displayPrice} - {offer.pricingPhases.pricingPhaseList.length > 0 && ( + Type: + {offer.type} + + {offer.paymentMode && ( <> - Pricing Phases: + Payment Mode: + + + {offer.paymentMode} - {offer.pricingPhases.pricingPhaseList.map( - (phase, phaseIndex) => ( - - - Phase {phaseIndex + 1}: {phase.formattedPrice} /{' '} - {phase.billingPeriod} - - - Cycles: {phase.billingCycleCount}, Mode:{' '} - {phase.recurrenceMode} - - - ), - )} )} - {offer.offerTags.length > 0 && ( + {offer.period && ( <> - Tags: + Period: - {offer.offerTags.join(', ')} + {offer.period.value} {offer.period.unit} )} - Offer Token: - - {offer.offerToken} - + {offer.basePlanIdAndroid && ( + <> + + Base Plan ID: + + + {offer.basePlanIdAndroid} + + + )} + + {offer.pricingPhasesAndroid?.pricingPhaseList && + offer.pricingPhasesAndroid.pricingPhaseList.length > + 0 && ( + <> + + Pricing Phases: + + {offer.pricingPhasesAndroid.pricingPhaseList.map( + (phase, phaseIndex) => ( + + + Phase {phaseIndex + 1}: {phase.formattedPrice}{' '} + / {phase.billingPeriod} + + + Cycles: {phase.billingCycleCount}, Mode:{' '} + {phase.recurrenceMode} + + + ), + )} + + )} + + {Array.isArray(offer.offerTagsAndroid) && + offer.offerTagsAndroid.length > 0 && ( + <> + Tags: + + {offer.offerTagsAndroid.join(', ')} + + + )} + + {offer.offerTokenAndroid && ( + <> + + Offer Token: + + + {offer.offerTokenAndroid} + + + )} ), )} )} - {/* Android One-Time Purchase Offers */} - {hasOneTimeOffers && ( - + {/* Discount Offers (Cross-platform) */} + {hasDiscountOffers && ( + + + Discount Offers ({selectedSubscription.discountOffers!.length}) + + {selectedSubscription.discountOffers!.map((offer, idx) => ( + + + {offer.id || `Offer ${idx + 1}`} + + Price: + {offer.displayPrice} + {offer.fullPriceMicrosAndroid && ( + <> + + Full Price (micros): + + + {offer.fullPriceMicrosAndroid} + + + )} + {offer.percentageDiscountAndroid && ( + + {offer.percentageDiscountAndroid}% off + + )} + {offer.formattedDiscountAmountAndroid && ( + <> + Discount: + + {offer.formattedDiscountAmountAndroid} + + + )} + + ))} + )} {/* Raw JSON Section */} @@ -679,16 +746,23 @@ function SubscriptionFlow({ }; const renderSubscriptionPrice = (subscription: ProductSubscription) => { + // Use cross-platform subscriptionOffers first if ( - 'subscriptionOfferDetailsAndroid' in subscription && - subscription.subscriptionOfferDetailsAndroid + 'subscriptionOffers' in subscription && + subscription.subscriptionOffers ) { - const offers = subscription.subscriptionOfferDetailsAndroid; + const offers = subscription.subscriptionOffers; if (offers && offers.length > 0) { const firstOffer = offers[0]; - if (firstOffer && firstOffer.pricingPhases) { - const pricingPhaseList = firstOffer.pricingPhases.pricingPhaseList; - if (pricingPhaseList && pricingPhaseList.length > 0) { + // Use displayPrice from offer if available + if (firstOffer?.displayPrice) { + return firstOffer.displayPrice; + } + // Fallback to pricingPhasesAndroid + if (firstOffer?.pricingPhasesAndroid?.pricingPhaseList) { + const pricingPhaseList = + firstOffer.pricingPhasesAndroid.pricingPhaseList; + if (pricingPhaseList.length > 0) { const firstPhase = pricingPhaseList[0]; if (firstPhase) { return firstPhase.formattedPrice; @@ -1600,20 +1674,19 @@ function SubscriptionFlowContainer() { JSON.stringify(purchaseData, null, 2), ); - // Map offerToken to basePlanId using fetched subscription data + // Map offerToken to basePlanId using fetched subscription data (cross-platform) if (purchaseData.offerToken) { const premiumSub = subscriptions.find( (s) => s.id === 'dev.hyo.martie.premium', ) as ProductSubscriptionAndroid; - const matchingOffer = - premiumSub?.subscriptionOfferDetailsAndroid?.find( - (offer) => offer.offerToken === purchaseData.offerToken, - ); - if (matchingOffer?.basePlanId) { - setLastPurchasedPlan(matchingOffer.basePlanId); + const matchingOffer = premiumSub?.subscriptionOffers?.find( + (offer) => offer.offerTokenAndroid === purchaseData.offerToken, + ); + if (matchingOffer?.basePlanIdAndroid) { + setLastPurchasedPlan(matchingOffer.basePlanIdAndroid); console.log( 'Detected plan from offerToken (Android):', - matchingOffer.basePlanId, + matchingOffer.basePlanIdAndroid, ); } else { // Fallback if we can't find the matching offer @@ -1920,15 +1993,17 @@ function SubscriptionFlowContainer() { } } - // Android specific fields - if ( - Platform.OS === 'android' && - 'subscriptionOfferDetailsAndroid' in sub - ) { + // Cross-platform subscription offers + if ('subscriptionOffers' in sub && sub.subscriptionOffers) { + console.log( + ` • subscriptionOffers: ${sub.subscriptionOffers.length || 0} offer(s)`, + ); + } + + // Cross-platform discount offers + if ('discountOffers' in sub && sub.discountOffers) { console.log( - ` • subscriptionOfferDetailsAndroid: ${ - sub.subscriptionOfferDetailsAndroid?.length || 0 - } offer(s)`, + ` • discountOffers: ${sub.discountOffers.length || 0} offer(s)`, ); } }); @@ -2070,7 +2145,7 @@ function SubscriptionFlowContainer() { // // Android: Requires subscriptionOffers with offerToken // Each offer represents a base plan (monthly/yearly) or promotional offer - // The offerToken is obtained from subscriptionOfferDetailsAndroid + // The offerToken is obtained from subscriptionOffers (cross-platform type) const handleSubscription = useCallback( (itemId: string) => { setIsProcessing(true); @@ -2088,14 +2163,13 @@ function SubscriptionFlowContainer() { skus: [itemId], subscriptionOffers: subscription && - 'subscriptionOfferDetailsAndroid' in subscription && - (subscription as ProductSubscriptionAndroid) - .subscriptionOfferDetailsAndroid + 'subscriptionOffers' in subscription && + (subscription as ProductSubscriptionAndroid).subscriptionOffers ? ( subscription as ProductSubscriptionAndroid - ).subscriptionOfferDetailsAndroid.map((offer) => ({ + ).subscriptionOffers.map((offer) => ({ sku: itemId, - offerToken: offer.offerToken, + offerToken: offer.offerTokenAndroid ?? '', })) : [], }, From 9ccb8fcf934de8cb56ba63d005b4df435f1fac79 Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 17 Jan 2026 09:17:09 +0900 Subject: [PATCH 4/7] fix: ensure subscriptionOffers defaults to empty array for Android subscriptions Address coderabbit review: ProductSubscriptionAndroid.subscriptionOffers is non-nullable in the public types, so parsing failures should default to [] instead of null. Co-Authored-By: Claude Opus 4.5 --- src/__tests__/utils/type-bridge.test.ts | 5 +++-- src/utils/type-bridge.ts | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/__tests__/utils/type-bridge.test.ts b/src/__tests__/utils/type-bridge.test.ts index 476398335..562d855da 100644 --- a/src/__tests__/utils/type-bridge.test.ts +++ b/src/__tests__/utils/type-bridge.test.ts @@ -260,7 +260,7 @@ describe('type-bridge utilities', () => { expect(result.discountOffers).toBeNull(); }); - it('handles invalid JSON in subscriptionOffers gracefully', () => { + it('handles invalid JSON in subscriptionOffers gracefully for Android subscription', () => { const nitroProduct: NitroProduct = { id: 'com.example.product', title: 'Test Product', @@ -272,7 +272,8 @@ describe('type-bridge utilities', () => { const result = convertNitroProductToProduct(nitroProduct) as any; - expect(result.subscriptionOffers).toBeNull(); + // Android subscription type requires non-nullable subscriptionOffers, so it defaults to empty array + expect(result.subscriptionOffers).toEqual([]); }); }); diff --git a/src/utils/type-bridge.ts b/src/utils/type-bridge.ts index c0cf27187..54452c776 100644 --- a/src/utils/type-bridge.ts +++ b/src/utils/type-bridge.ts @@ -339,9 +339,14 @@ export function convertNitroProductToProduct( } if (type === PRODUCT_TYPE_SUBS) { + // Ensure subscriptionOfferDetailsAndroid is always an array for subscriptions if (!Array.isArray(androidProduct.subscriptionOfferDetailsAndroid)) { androidProduct.subscriptionOfferDetailsAndroid = []; } + // Ensure subscriptionOffers is always an array for subscriptions (non-nullable in ProductSubscriptionAndroid) + if (!Array.isArray(androidProduct.subscriptionOffers)) { + androidProduct.subscriptionOffers = []; + } } return androidProduct as Product; From 0f7160525602847e3fd21211d08afab211ab7ac7 Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 17 Jan 2026 09:39:11 +0900 Subject: [PATCH 5/7] chore: bump openiap-google to 1.3.23 --- openiap-versions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openiap-versions.json b/openiap-versions.json index 474c80c32..9339e7fdb 100644 --- a/openiap-versions.json +++ b/openiap-versions.json @@ -1,6 +1,6 @@ { "apple": "1.3.10", - "google": "1.3.22", + "google": "1.3.23", "gql": "1.3.12", "docs": "1.3.7" } From 2fd6269315d0efe7a62d2bf171d7770b5bfe4164 Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 17 Jan 2026 10:09:53 +0900 Subject: [PATCH 6/7] feat(android): add subscriptionProductReplacementParams support Add item-level subscription replacement parameters support for Android Billing Library 8.1.0+. This allows specifying replacement mode per product instead of globally, enabling more flexible subscription upgrade/downgrade flows. Co-Authored-By: Claude Opus 4.5 --- .../java/com/margelo/nitro/iap/HybridRnIap.kt | 29 ++++++++++++++++++- src/specs/RnIap.nitro.ts | 7 +++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index cc7fa5c93..bd89ec06a 100644 --- a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -27,6 +27,8 @@ import dev.hyo.openiap.RequestPurchaseResultPurchase import dev.hyo.openiap.RequestPurchaseResultPurchases import dev.hyo.openiap.RequestSubscriptionAndroidProps import dev.hyo.openiap.RequestSubscriptionPropsByPlatforms +import dev.hyo.openiap.SubscriptionProductReplacementParamsAndroid as OpenIapSubscriptionProductReplacementParams +import dev.hyo.openiap.SubscriptionReplacementModeAndroid as OpenIapSubscriptionReplacementMode import dev.hyo.openiap.VerifyPurchaseGoogleOptions import dev.hyo.openiap.VerifyPurchaseProps import dev.hyo.openiap.VerifyPurchaseResultAndroid @@ -414,6 +416,15 @@ class HybridRnIap : HybridRnIapSpec() { val requestProps = when (queryType) { ProductQueryType.Subs -> { val replacementMode = (androidRequest.replacementModeAndroid as? Number)?.toInt() + + // Parse subscriptionProductReplacementParams (8.1.0+) + val subscriptionProductReplacementParams = androidRequest.subscriptionProductReplacementParams?.let { params -> + OpenIapSubscriptionProductReplacementParams( + oldProductId = params.oldProductId, + replacementMode = parseSubscriptionReplacementMode(params.replacementMode) + ) + } + val androidProps = RequestSubscriptionAndroidProps( isOfferPersonalized = androidRequest.isOfferPersonalized, obfuscatedAccountIdAndroid = androidRequest.obfuscatedAccountIdAndroid, @@ -421,7 +432,8 @@ class HybridRnIap : HybridRnIapSpec() { purchaseTokenAndroid = androidRequest.purchaseTokenAndroid, replacementModeAndroid = replacementMode, skus = androidRequest.skus.toList(), - subscriptionOffers = normalizedOffers + subscriptionOffers = normalizedOffers, + subscriptionProductReplacementParams = subscriptionProductReplacementParams ) RequestPurchaseProps( request = RequestPurchaseProps.Request.Subscription( @@ -795,6 +807,21 @@ class HybridRnIap : HybridRnIapSpec() { } } + /** + * Parse subscription replacement mode from Nitro enum to OpenIAP enum (8.1.0+) + */ + private fun parseSubscriptionReplacementMode(mode: SubscriptionReplacementModeAndroid): OpenIapSubscriptionReplacementMode { + return when (mode) { + SubscriptionReplacementModeAndroid.WITH_TIME_PRORATION -> OpenIapSubscriptionReplacementMode.WithTimeProration + SubscriptionReplacementModeAndroid.CHARGE_PRORATED_PRICE -> OpenIapSubscriptionReplacementMode.ChargeProRatedPrice + SubscriptionReplacementModeAndroid.CHARGE_FULL_PRICE -> OpenIapSubscriptionReplacementMode.ChargeFullPrice + SubscriptionReplacementModeAndroid.WITHOUT_PRORATION -> OpenIapSubscriptionReplacementMode.WithoutProration + SubscriptionReplacementModeAndroid.DEFERRED -> OpenIapSubscriptionReplacementMode.Deferred + SubscriptionReplacementModeAndroid.KEEP_EXISTING -> OpenIapSubscriptionReplacementMode.KeepExisting + SubscriptionReplacementModeAndroid.UNKNOWN_REPLACEMENT_MODE -> OpenIapSubscriptionReplacementMode.UnknownReplacementMode + } + } + private fun FetchProductsResult.productsOrEmpty(): List = when (this) { is FetchProductsResultProducts -> this.value.orEmpty().filterIsInstance() is FetchProductsResultSubscriptions -> this.value.orEmpty().filterIsInstance() diff --git a/src/specs/RnIap.nitro.ts b/src/specs/RnIap.nitro.ts index 431c26b1b..a53025451 100644 --- a/src/specs/RnIap.nitro.ts +++ b/src/specs/RnIap.nitro.ts @@ -23,6 +23,7 @@ import type { RequestSubscriptionAndroidProps, UserChoiceBillingDetails, PaymentModeIOS, + SubscriptionProductReplacementParamsAndroid, } from '../types'; // Nitro-compatible enum types (Nitro doesn't support inline string unions from types.ts) @@ -125,8 +126,14 @@ export interface NitroRequestPurchaseAndroid { obfuscatedProfileIdAndroid?: RequestSubscriptionAndroidProps['obfuscatedProfileIdAndroid']; isOfferPersonalized?: RequestSubscriptionAndroidProps['isOfferPersonalized']; subscriptionOffers?: AndroidSubscriptionOfferInput[] | null; + /** @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) */ replacementModeAndroid?: RequestSubscriptionAndroidProps['replacementModeAndroid']; purchaseTokenAndroid?: RequestSubscriptionAndroidProps['purchaseTokenAndroid']; + /** + * Product-level replacement parameters (8.1.0+) + * Use this instead of replacementModeAndroid for item-level replacement + */ + subscriptionProductReplacementParams?: SubscriptionProductReplacementParamsAndroid | null; } export interface NitroPurchaseRequest { From f8c4680e76bd968e74150ad9e28509c4012f434b Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 17 Jan 2026 10:23:46 +0900 Subject: [PATCH 7/7] fix(android): correct ChargeProratedPrice enum name Fix typo in OpenIAP enum mapping: ChargeProratedPrice (not ChargeProRatedPrice) Co-Authored-By: Claude Opus 4.5 --- android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index bd89ec06a..2dc3f8ccf 100644 --- a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -813,7 +813,7 @@ class HybridRnIap : HybridRnIapSpec() { private fun parseSubscriptionReplacementMode(mode: SubscriptionReplacementModeAndroid): OpenIapSubscriptionReplacementMode { return when (mode) { SubscriptionReplacementModeAndroid.WITH_TIME_PRORATION -> OpenIapSubscriptionReplacementMode.WithTimeProration - SubscriptionReplacementModeAndroid.CHARGE_PRORATED_PRICE -> OpenIapSubscriptionReplacementMode.ChargeProRatedPrice + SubscriptionReplacementModeAndroid.CHARGE_PRORATED_PRICE -> OpenIapSubscriptionReplacementMode.ChargeProratedPrice SubscriptionReplacementModeAndroid.CHARGE_FULL_PRICE -> OpenIapSubscriptionReplacementMode.ChargeFullPrice SubscriptionReplacementModeAndroid.WITHOUT_PRORATION -> OpenIapSubscriptionReplacementMode.WithoutProration SubscriptionReplacementModeAndroid.DEFERRED -> OpenIapSubscriptionReplacementMode.Deferred