Skip to content
This repository was archived by the owner on Apr 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 128 additions & 1 deletion android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -414,14 +416,24 @@ 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,
obfuscatedProfileIdAndroid = androidRequest.obfuscatedProfileIdAndroid,
purchaseTokenAndroid = androidRequest.purchaseTokenAndroid,
replacementModeAndroid = replacementMode,
skus = androidRequest.skus.toList(),
subscriptionOffers = normalizedOffers
subscriptionOffers = normalizedOffers,
subscriptionProductReplacementParams = subscriptionProductReplacementParams
)
RequestPurchaseProps(
request = RequestPurchaseProps.Request.Subscription(
Expand Down Expand Up @@ -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<ProductCommon> = when (this) {
is FetchProductsResultProducts -> this.value.orEmpty().filterIsInstance<ProductCommon>()
is FetchProductsResultSubscriptions -> this.value.orEmpty().filterIsInstance<ProductCommon>()
Expand Down Expand Up @@ -837,6 +864,84 @@ class HybridRnIap : HybridRnIapSpec() {
return array.toString()
}

/**
* Serialize standardized SubscriptionOffer list to JSON string (OpenIAP 1.3.10+)
*/
private fun serializeStandardizedSubscriptionOffers(offers: List<dev.hyo.openiap.SubscriptionOffer>): 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<dev.hyo.openiap.DiscountOffer>): 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()
Expand Down Expand Up @@ -940,6 +1045,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,
Expand All @@ -961,6 +1086,8 @@ class HybridRnIap : HybridRnIapSpec() {
introductoryPricePaymentModeIOS = PaymentModeIOS.EMPTY,
introductoryPriceNumberOfPeriodsIOS = null,
introductoryPriceSubscriptionPeriodIOS = null,
subscriptionOffers = subscriptionOffersStandardizedJson,
discountOffers = discountOffersJson,
nameAndroid = nameAndroid,
originalPriceAndroid = originalPriceAndroid,
originalPriceAmountMicrosAndroid = originalPriceAmountMicrosAndroid,
Expand Down
114 changes: 114 additions & 0 deletions docs/docs/api/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading