Skip to content
This repository was archived by the owner on Aug 4, 2026. It is now read-only.

Commit a02210b

Browse files
hyochanclaude
andauthored
feat: sync with openiap v1.3.12 (#3125)
## Summary - Sync with openiap v1.3.12 (gql: 1.3.12, apple: 1.3.10, google: 1.3.22) - Add new cross-platform `DiscountOffer` and `SubscriptionOffer` types - New `discountOffers` and `subscriptionOffers` fields on Product types ## Changes ### Types (auto-generated) - New `DiscountOffer` type with cross-platform fields - New `SubscriptionOffer` type with cross-platform fields - Platform-specific fields use `Android`/`IOS` suffixes - Existing `subscriptionOfferDetailsAndroid` and `oneTimePurchaseOfferDetailsAndroid` still available for compatibility ## Test plan - [x] `yarn typecheck` passes - [x] `yarn test` passes (172 tests) - [x] `yarn build` passes - [x] Nitro code generation succeeds 🤖 Generated with [Claude Code](https://claude.ai/code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Unified cross-platform subscription and discount offer data now exposed to apps and UI; product details show standardized subscription and discount offers. * Improved subscription replacement support during purchase flows. * **Bug Fixes** * More robust handling when standardized offer data is missing or malformed. * **Documentation** * API docs and guides updated with unified offer types, examples, and deprecation notes for legacy fields. * **Tests** * Added tests covering parsing and display of standardized offers. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 14bfac0 commit a02210b

15 files changed

Lines changed: 1458 additions & 267 deletions

File tree

android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import dev.hyo.openiap.RequestPurchaseResultPurchase
2727
import dev.hyo.openiap.RequestPurchaseResultPurchases
2828
import dev.hyo.openiap.RequestSubscriptionAndroidProps
2929
import dev.hyo.openiap.RequestSubscriptionPropsByPlatforms
30+
import dev.hyo.openiap.SubscriptionProductReplacementParamsAndroid as OpenIapSubscriptionProductReplacementParams
31+
import dev.hyo.openiap.SubscriptionReplacementModeAndroid as OpenIapSubscriptionReplacementMode
3032
import dev.hyo.openiap.VerifyPurchaseGoogleOptions
3133
import dev.hyo.openiap.VerifyPurchaseProps
3234
import dev.hyo.openiap.VerifyPurchaseResultAndroid
@@ -414,14 +416,24 @@ class HybridRnIap : HybridRnIapSpec() {
414416
val requestProps = when (queryType) {
415417
ProductQueryType.Subs -> {
416418
val replacementMode = (androidRequest.replacementModeAndroid as? Number)?.toInt()
419+
420+
// Parse subscriptionProductReplacementParams (8.1.0+)
421+
val subscriptionProductReplacementParams = androidRequest.subscriptionProductReplacementParams?.let { params ->
422+
OpenIapSubscriptionProductReplacementParams(
423+
oldProductId = params.oldProductId,
424+
replacementMode = parseSubscriptionReplacementMode(params.replacementMode)
425+
)
426+
}
427+
417428
val androidProps = RequestSubscriptionAndroidProps(
418429
isOfferPersonalized = androidRequest.isOfferPersonalized,
419430
obfuscatedAccountIdAndroid = androidRequest.obfuscatedAccountIdAndroid,
420431
obfuscatedProfileIdAndroid = androidRequest.obfuscatedProfileIdAndroid,
421432
purchaseTokenAndroid = androidRequest.purchaseTokenAndroid,
422433
replacementModeAndroid = replacementMode,
423434
skus = androidRequest.skus.toList(),
424-
subscriptionOffers = normalizedOffers
435+
subscriptionOffers = normalizedOffers,
436+
subscriptionProductReplacementParams = subscriptionProductReplacementParams
425437
)
426438
RequestPurchaseProps(
427439
request = RequestPurchaseProps.Request.Subscription(
@@ -795,6 +807,21 @@ class HybridRnIap : HybridRnIapSpec() {
795807
}
796808
}
797809

810+
/**
811+
* Parse subscription replacement mode from Nitro enum to OpenIAP enum (8.1.0+)
812+
*/
813+
private fun parseSubscriptionReplacementMode(mode: SubscriptionReplacementModeAndroid): OpenIapSubscriptionReplacementMode {
814+
return when (mode) {
815+
SubscriptionReplacementModeAndroid.WITH_TIME_PRORATION -> OpenIapSubscriptionReplacementMode.WithTimeProration
816+
SubscriptionReplacementModeAndroid.CHARGE_PRORATED_PRICE -> OpenIapSubscriptionReplacementMode.ChargeProratedPrice
817+
SubscriptionReplacementModeAndroid.CHARGE_FULL_PRICE -> OpenIapSubscriptionReplacementMode.ChargeFullPrice
818+
SubscriptionReplacementModeAndroid.WITHOUT_PRORATION -> OpenIapSubscriptionReplacementMode.WithoutProration
819+
SubscriptionReplacementModeAndroid.DEFERRED -> OpenIapSubscriptionReplacementMode.Deferred
820+
SubscriptionReplacementModeAndroid.KEEP_EXISTING -> OpenIapSubscriptionReplacementMode.KeepExisting
821+
SubscriptionReplacementModeAndroid.UNKNOWN_REPLACEMENT_MODE -> OpenIapSubscriptionReplacementMode.UnknownReplacementMode
822+
}
823+
}
824+
798825
private fun FetchProductsResult.productsOrEmpty(): List<ProductCommon> = when (this) {
799826
is FetchProductsResultProducts -> this.value.orEmpty().filterIsInstance<ProductCommon>()
800827
is FetchProductsResultSubscriptions -> this.value.orEmpty().filterIsInstance<ProductCommon>()
@@ -837,6 +864,84 @@ class HybridRnIap : HybridRnIapSpec() {
837864
return array.toString()
838865
}
839866

867+
/**
868+
* Serialize standardized SubscriptionOffer list to JSON string (OpenIAP 1.3.10+)
869+
*/
870+
private fun serializeStandardizedSubscriptionOffers(offers: List<dev.hyo.openiap.SubscriptionOffer>): String {
871+
val array = JSONArray()
872+
offers.forEach { offer ->
873+
val offerJson = JSONObject()
874+
offerJson.put("id", offer.id)
875+
offerJson.put("displayPrice", offer.displayPrice)
876+
offerJson.put("price", offer.price)
877+
offerJson.put("type", offer.type.rawValue)
878+
offer.currency?.let { offerJson.put("currency", it) }
879+
offer.basePlanIdAndroid?.let { offerJson.put("basePlanIdAndroid", it) }
880+
offer.offerTokenAndroid?.let { offerJson.put("offerTokenAndroid", it) }
881+
offer.offerTagsAndroid?.let { offerJson.put("offerTagsAndroid", JSONArray(it)) }
882+
offer.paymentMode?.let { offerJson.put("paymentMode", it.rawValue) }
883+
offer.periodCount?.let { offerJson.put("periodCount", it) }
884+
offer.numberOfPeriodsIOS?.let { offerJson.put("numberOfPeriodsIOS", it) }
885+
offer.period?.let { period ->
886+
val periodJson = JSONObject()
887+
periodJson.put("unit", period.unit.rawValue)
888+
periodJson.put("value", period.value)
889+
offerJson.put("period", periodJson)
890+
}
891+
offer.pricingPhasesAndroid?.let { phases ->
892+
val phasesJson = JSONObject()
893+
val phaseList = JSONArray()
894+
phases.pricingPhaseList.forEach { phase ->
895+
val phaseJson = JSONObject()
896+
phaseJson.put("billingCycleCount", phase.billingCycleCount)
897+
phaseJson.put("billingPeriod", phase.billingPeriod)
898+
phaseJson.put("formattedPrice", phase.formattedPrice)
899+
phaseJson.put("priceAmountMicros", phase.priceAmountMicros)
900+
phaseJson.put("priceCurrencyCode", phase.priceCurrencyCode)
901+
phaseJson.put("recurrenceMode", phase.recurrenceMode)
902+
phaseList.put(phaseJson)
903+
}
904+
phasesJson.put("pricingPhaseList", phaseList)
905+
offerJson.put("pricingPhasesAndroid", phasesJson)
906+
}
907+
array.put(offerJson)
908+
}
909+
return array.toString()
910+
}
911+
912+
/**
913+
* Serialize standardized DiscountOffer list to JSON string (OpenIAP 1.3.10+)
914+
*/
915+
private fun serializeStandardizedDiscountOffers(offers: List<dev.hyo.openiap.DiscountOffer>): String {
916+
val array = JSONArray()
917+
offers.forEach { offer ->
918+
val offerJson = JSONObject()
919+
offerJson.put("currency", offer.currency)
920+
offerJson.put("displayPrice", offer.displayPrice)
921+
offerJson.put("price", offer.price)
922+
offer.id?.let { offerJson.put("id", it) }
923+
offer.offerTagsAndroid?.let { offerJson.put("offerTagsAndroid", JSONArray(it)) }
924+
offer.offerTokenAndroid?.let { offerJson.put("offerTokenAndroid", it) }
925+
offer.discountAmountMicrosAndroid?.let { offerJson.put("discountAmountMicrosAndroid", it) }
926+
offer.formattedDiscountAmountAndroid?.let { offerJson.put("formattedDiscountAmountAndroid", it) }
927+
offer.fullPriceMicrosAndroid?.let { offerJson.put("fullPriceMicrosAndroid", it) }
928+
offer.limitedQuantityInfoAndroid?.let { info ->
929+
val infoJson = JSONObject()
930+
infoJson.put("maximumQuantity", info.maximumQuantity)
931+
infoJson.put("remainingQuantity", info.remainingQuantity)
932+
offerJson.put("limitedQuantityInfoAndroid", infoJson)
933+
}
934+
offer.validTimeWindowAndroid?.let { window ->
935+
val windowJson = JSONObject()
936+
windowJson.put("startTimeMillis", window.startTimeMillis)
937+
windowJson.put("endTimeMillis", window.endTimeMillis)
938+
offerJson.put("validTimeWindowAndroid", windowJson)
939+
}
940+
array.put(offerJson)
941+
}
942+
return array.toString()
943+
}
944+
840945
private fun convertToNitroProduct(product: ProductCommon): NitroProduct {
841946
val subscriptionOffers = when (product) {
842947
is ProductSubscriptionAndroid -> product.subscriptionOfferDetailsAndroid.orEmpty()
@@ -940,6 +1045,26 @@ class HybridRnIap : HybridRnIapSpec() {
9401045
else -> null
9411046
}
9421047

1048+
// Serialize standardized cross-platform subscriptionOffers (OpenIAP 1.3.10+)
1049+
val standardizedSubsOffers = when (product) {
1050+
is ProductSubscriptionAndroid -> product.subscriptionOffers
1051+
is ProductAndroid -> product.subscriptionOffers
1052+
else -> null
1053+
}
1054+
val subscriptionOffersStandardizedJson = standardizedSubsOffers?.takeIf { it.isNotEmpty() }?.let {
1055+
serializeStandardizedSubscriptionOffers(it)
1056+
}
1057+
1058+
// Serialize standardized cross-platform discountOffers (OpenIAP 1.3.10+)
1059+
val standardizedDiscountOffers = when (product) {
1060+
is ProductSubscriptionAndroid -> product.discountOffers
1061+
is ProductAndroid -> product.discountOffers
1062+
else -> null
1063+
}
1064+
val discountOffersJson = standardizedDiscountOffers?.takeIf { it.isNotEmpty() }?.let {
1065+
serializeStandardizedDiscountOffers(it)
1066+
}
1067+
9431068
return NitroProduct(
9441069
id = product.id,
9451070
title = product.title,
@@ -961,6 +1086,8 @@ class HybridRnIap : HybridRnIapSpec() {
9611086
introductoryPricePaymentModeIOS = PaymentModeIOS.EMPTY,
9621087
introductoryPriceNumberOfPeriodsIOS = null,
9631088
introductoryPriceSubscriptionPeriodIOS = null,
1089+
subscriptionOffers = subscriptionOffersStandardizedJson,
1090+
discountOffers = discountOffersJson,
9641091
nameAndroid = nameAndroid,
9651092
originalPriceAndroid = originalPriceAndroid,
9661093
originalPriceAmountMicrosAndroid = originalPriceAmountMicrosAndroid,

docs/docs/api/types.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,120 @@ export interface RentalDetailsAndroid {
137137
}
138138
```
139139

140+
### Cross-Platform Offer Types (v14.8.0+)
141+
142+
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.
143+
144+
#### SubscriptionOffer
145+
146+
The `subscriptionOffers` field is available on both `ProductIOS` and `ProductAndroid` for subscription products:
147+
148+
```ts
149+
export interface SubscriptionOffer {
150+
/** Unique identifier for the offer */
151+
id: string;
152+
/** Formatted display price (e.g., "$9.99/month" or "Free") */
153+
displayPrice: string;
154+
/** Numeric price value */
155+
price: number;
156+
/** Type of offer: 'introductory' or 'promotional' */
157+
type: DiscountOfferType;
158+
/** Currency code (ISO 4217, e.g., "USD") */
159+
currency?: string | null;
160+
/** Payment mode during the offer period */
161+
paymentMode?: PaymentMode | null;
162+
/** Subscription period for this offer */
163+
period?: SubscriptionPeriod | null;
164+
/** Number of periods the offer applies */
165+
periodCount?: number | null;
166+
// iOS-specific fields
167+
/** [iOS] Key identifier for signature validation */
168+
keyIdentifierIOS?: string | null;
169+
/** [iOS] Number of billing periods for this discount */
170+
numberOfPeriodsIOS?: number | null;
171+
// Android-specific fields
172+
/** [Android] Base plan identifier */
173+
basePlanIdAndroid?: string | null;
174+
/** [Android] Offer token required for purchase */
175+
offerTokenAndroid?: string | null;
176+
/** [Android] List of tags associated with this offer */
177+
offerTagsAndroid?: string[] | null;
178+
/** [Android] Pricing phases for this subscription offer */
179+
pricingPhasesAndroid?: PricingPhasesAndroid | null;
180+
}
181+
182+
export type DiscountOfferType = 'introductory' | 'promotional';
183+
export type PaymentMode = 'free-trial' | 'pay-as-you-go' | 'pay-up-front';
184+
185+
export interface SubscriptionPeriod {
186+
unit: SubscriptionPeriodUnit;
187+
value: number;
188+
}
189+
190+
export type SubscriptionPeriodUnit = 'day' | 'week' | 'month' | 'year' | 'unknown';
191+
```
192+
193+
#### DiscountOffer
194+
195+
The `discountOffers` field is available on both platforms for one-time purchase products with discounts:
196+
197+
```ts
198+
export interface DiscountOffer {
199+
/** Currency code (ISO 4217, e.g., "USD") */
200+
currency: string;
201+
/** Formatted display price (e.g., "$4.99") */
202+
displayPrice: string;
203+
/** Numeric price value */
204+
price: number;
205+
/** Unique identifier for the offer (Android only) */
206+
id?: string | null;
207+
// Android-specific fields
208+
/** [Android] Fixed discount amount in micro-units */
209+
discountAmountMicrosAndroid?: string | null;
210+
/** [Android] Formatted discount amount (e.g., "$5.00 OFF") */
211+
formattedDiscountAmountAndroid?: string | null;
212+
/** [Android] Original full price in micro-units before discount */
213+
fullPriceMicrosAndroid?: string | null;
214+
/** [Android] Offer token required for purchase */
215+
offerTokenAndroid?: string | null;
216+
/** [Android] List of tags associated with this offer */
217+
offerTagsAndroid?: string[] | null;
218+
/** [Android] Limited quantity information */
219+
limitedQuantityInfoAndroid?: LimitedQuantityInfoAndroid | null;
220+
/** [Android] Time window when the offer is valid */
221+
validTimeWindowAndroid?: ValidTimeWindowAndroid | null;
222+
}
223+
```
224+
225+
#### Usage Example
226+
227+
```tsx
228+
import {fetchProducts} from 'react-native-iap';
229+
230+
const products = await fetchProducts({skus: ['premium_monthly']});
231+
232+
// Access cross-platform subscription offers
233+
products.forEach(product => {
234+
if (product.subscriptionOffers) {
235+
product.subscriptionOffers.forEach(offer => {
236+
console.log(`Offer: ${offer.id}`);
237+
console.log(`Type: ${offer.type}`); // 'introductory' or 'promotional'
238+
console.log(`Price: ${offer.displayPrice}`);
239+
console.log(`Payment Mode: ${offer.paymentMode}`); // 'free-trial', etc.
240+
241+
// Platform-specific details
242+
if (offer.offerTokenAndroid) {
243+
console.log(`Android Token: ${offer.offerTokenAndroid}`);
244+
}
245+
});
246+
}
247+
});
248+
```
249+
250+
:::tip Deprecation Notice
251+
The platform-specific fields `subscriptionInfoIOS` and `subscriptionOfferDetailsAndroid` are now deprecated. Use the unified `subscriptionOffers` and `discountOffers` fields for new implementations.
252+
:::
253+
140254
## Purchase Types
141255

142256
Purchases share the `PurchaseCommon` shape and discriminate on the same `platform` union. Both variants expose the unified `purchaseToken` field for server validation.

0 commit comments

Comments
 (0)