From 8023a84f5d7163432801d3ec8b3c4e97485ad74a Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sun, 18 Jan 2026 22:10:48 +0900
Subject: [PATCH 01/11] feat(gql): add win-back offer, product status, and JWS
promo types
iOS (StoreKit 2):
- WinBackOfferInputIOS for iOS 18+ win-back offers
- PromotionalOfferJWSInputIOS for WWDC 2025 JWS format (iOS 15+)
- introductoryOfferEligibility override option
- SubscriptionOfferTypeIOS.WinBack enum value
Android (Billing 8.0+):
- ProductStatusAndroid enum (OK, NOT_FOUND, NO_OFFERS_AVAILABLE, UNKNOWN)
- productStatusAndroid field on ProductAndroid and ProductSubscriptionAndroid
Co-Authored-By: Claude Opus 4.5
---
packages/gql/src/type-android.graphql | 84 +++++++++++++++++++++++++++
packages/gql/src/type-ios.graphql | 76 ++++++++++++++++++++++++
packages/gql/src/type.graphql | 9 +++
3 files changed, 169 insertions(+)
diff --git a/packages/gql/src/type-android.graphql b/packages/gql/src/type-android.graphql
index 2c197edc..128c6875 100644
--- a/packages/gql/src/type-android.graphql
+++ b/packages/gql/src/type-android.graphql
@@ -1,5 +1,31 @@
# Android-specific GraphQL Types
+# Product-level status codes (Google Play Billing Library 8.0+)
+"""
+Status code for individual products returned from queryProductDetailsAsync (Android)
+Prior to 8.0, products that couldn't be fetched were simply not returned.
+With 8.0+, these products are returned with a status code explaining why.
+Available in Google Play Billing Library 8.0.0+
+"""
+enum ProductStatusAndroid {
+ """
+ Product was successfully fetched
+ """
+ OK
+ """
+ Product not found - the SKU doesn't exist in the Play Console
+ """
+ NOT_FOUND
+ """
+ No offers available for the user - product exists but user is not eligible for any offers
+ """
+ NO_OFFERS_AVAILABLE
+ """
+ Unknown error occurred while fetching the product
+ """
+ UNKNOWN
+}
+
# Android pricing phases
type PricingPhasesAndroid {
pricingPhaseList: [PricingPhaseAndroid!]!
@@ -190,6 +216,14 @@ type ProductAndroid implements ProductCommon {
# Android-specific
nameAndroid: String!
+ """
+ Product-level status code indicating fetch result (Android 8.0+)
+ OK = product fetched successfully
+ NOT_FOUND = SKU doesn't exist
+ NO_OFFERS_AVAILABLE = user not eligible for any offers
+ Available in Google Play Billing Library 8.0.0+
+ """
+ productStatusAndroid: ProductStatusAndroid
# Standardized cross-platform fields
"""
@@ -236,6 +270,14 @@ type ProductSubscriptionAndroid implements ProductCommon {
# Android-specific
nameAndroid: String!
+ """
+ Product-level status code indicating fetch result (Android 8.0+)
+ OK = product fetched successfully
+ NOT_FOUND = SKU doesn't exist
+ NO_OFFERS_AVAILABLE = user not eligible for any offers
+ Available in Google Play Billing Library 8.0.0+
+ """
+ productStatusAndroid: ProductStatusAndroid
# Standardized cross-platform fields
"""
@@ -795,3 +837,45 @@ type ExternalOfferAvailabilityResultAndroid
"""
isAvailable: Boolean!
}
+
+# Sub-Response Codes (Google Play Billing Library 8.0+)
+# See: https://developer.android.com/google/play/billing/release-notes
+
+"""
+Sub-response codes for more granular purchase error information (Android)
+Available in Google Play Billing Library 8.0.0+
+"""
+enum SubResponseCodeAndroid {
+ """
+ No specific sub-response code applies
+ """
+ NO_APPLICABLE_SUB_RESPONSE_CODE
+ """
+ User's payment method has insufficient funds
+ """
+ PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS
+ """
+ User doesn't meet subscription offer eligibility requirements
+ """
+ USER_INELIGIBLE
+}
+
+"""
+Extended billing result with sub-response code (Android)
+Available in Google Play Billing Library 8.0.0+
+"""
+type BillingResultAndroid {
+ """
+ The response code from the billing operation
+ """
+ responseCode: Int!
+ """
+ Debug message from the billing library
+ """
+ debugMessage: String
+ """
+ Sub-response code for more granular error information (8.0+).
+ Provides additional context when responseCode indicates an error.
+ """
+ subResponseCode: SubResponseCodeAndroid
+}
diff --git a/packages/gql/src/type-ios.graphql b/packages/gql/src/type-ios.graphql
index 3c2c445c..e5e3dd1c 100644
--- a/packages/gql/src/type-ios.graphql
+++ b/packages/gql/src/type-ios.graphql
@@ -30,6 +30,11 @@ enum PaymentModeIOS {
enum SubscriptionOfferTypeIOS {
Introductory
Promotional
+ """
+ Win-back offer type (iOS 18+)
+ Used to re-engage churned subscribers with a discount or free trial.
+ """
+ WinBack
}
# iOS subscription period (unit + value)
@@ -236,6 +241,25 @@ input RequestPurchaseIosProps {
"""
withOffer: DiscountOfferInputIOS
"""
+ Win-back offer to apply (iOS 18+)
+ Used to re-engage churned subscribers with a discount or free trial.
+ Note: Win-back offers only apply to subscription products.
+ """
+ winBackOffer: WinBackOfferInputIOS
+ """
+ JWS promotional offer (iOS 15+, WWDC 2025).
+ New signature format using compact JWS string for promotional offers.
+ Back-deployed to iOS 15.
+ """
+ promotionalOfferJWS: PromotionalOfferJWSInputIOS
+ """
+ Override introductory offer eligibility (iOS 15+, WWDC 2025).
+ Set to true to indicate the user is eligible for introductory offer,
+ or false to indicate they are not. When nil, the system determines eligibility.
+ Back-deployed to iOS 15.
+ """
+ introductoryOfferEligibility: Boolean
+ """
Advanced commerce data token (iOS 15+).
Used with StoreKit 2's Product.PurchaseOption.custom API for passing
campaign tokens, affiliate IDs, or other attribution data.
@@ -252,6 +276,26 @@ input RequestSubscriptionIosProps {
quantity: Int
withOffer: DiscountOfferInputIOS
"""
+ Win-back offer to apply (iOS 18+)
+ Used to re-engage churned subscribers with a discount or free trial.
+ The offer is available when the customer is eligible and can be discovered
+ via StoreKit Message (automatic) or subscription offer APIs.
+ """
+ winBackOffer: WinBackOfferInputIOS
+ """
+ JWS promotional offer (iOS 15+, WWDC 2025).
+ New signature format using compact JWS string for promotional offers.
+ Back-deployed to iOS 15.
+ """
+ promotionalOfferJWS: PromotionalOfferJWSInputIOS
+ """
+ Override introductory offer eligibility (iOS 15+, WWDC 2025).
+ Set to true to indicate the user is eligible for introductory offer,
+ or false to indicate they are not. When nil, the system determines eligibility.
+ Back-deployed to iOS 15.
+ """
+ introductoryOfferEligibility: Boolean
+ """
Advanced commerce data token (iOS 15+).
Used with StoreKit 2's Product.PurchaseOption.custom API for passing
campaign tokens, affiliate IDs, or other attribution data.
@@ -284,6 +328,38 @@ input DiscountOfferInputIOS {
timestamp: Float!
}
+"""
+Win-back offer input for iOS 18+ (StoreKit 2)
+Win-back offers are used to re-engage churned subscribers.
+The offer is automatically presented via StoreKit Message when eligible,
+or can be applied programmatically during purchase.
+"""
+input WinBackOfferInputIOS {
+ """
+ The win-back offer ID from App Store Connect
+ """
+ offerId: String!
+}
+
+"""
+JWS promotional offer input for iOS 15+ (StoreKit 2, WWDC 2025).
+New signature format using compact JWS string for promotional offers.
+This provides a simpler alternative to the legacy signature-based promotional offers.
+Back-deployed to iOS 15.
+"""
+input PromotionalOfferJWSInputIOS {
+ """
+ The promotional offer identifier from App Store Connect
+ """
+ offerId: String!
+ """
+ Compact JWS string signed by your server.
+ The JWS should contain the promotional offer signature data.
+ Format: header.payload.signature (base64url encoded)
+ """
+ jws: String!
+}
+
"""
Apple App Store verification parameters.
Used for server-side receipt validation via App Store Server API.
diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql
index d183cdcb..4c7acd76 100644
--- a/packages/gql/src/type.graphql
+++ b/packages/gql/src/type.graphql
@@ -135,6 +135,13 @@ input PurchaseOptions {
Limit to currently active items on iOS
"""
onlyIncludeActiveItemsIOS: Boolean
+ """
+ Include suspended subscriptions in the result (Android 8.1+).
+ Suspended subscriptions have isSuspendedAndroid=true and should NOT be granted entitlements.
+ Users should be directed to the subscription center to resolve payment issues.
+ Default: false (only active subscriptions are returned)
+ """
+ includeSuspendedAndroid: Boolean
}
# Parameters for requestPurchase
@@ -734,4 +741,6 @@ input InitConnectionConfig {
- EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+)
"""
enableBillingProgramAndroid: BillingProgramAndroid
+ # Note: enableAutoServiceReconnection is always enabled internally (Billing 8.0+)
+ # since OpenIAP uses Billing Library 8.3.0+
}
From f3095b3950761cdf29b2f7475384f97a2353171f Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sun, 18 Jan 2026 22:12:15 +0900
Subject: [PATCH 02/11] chore(gql): regenerate types for all platforms
Regenerate platform-specific types after schema changes in 8023a84.
Includes new types for Billing 8.0+ (ProductStatusAndroid, BillingResultAndroid,
SubResponseCodeAndroid) and iOS WWDC 2025 APIs (WinBackOfferInputIOS,
PromotionalOfferJwsInputIOS, introductoryOfferEligibility).
Co-Authored-By: Claude Opus 4.5
---
packages/gql/src/generated/Types.kt | 260 ++++++++++++++++++++++++-
packages/gql/src/generated/Types.swift | 135 +++++++++++++
packages/gql/src/generated/types.dart | 236 +++++++++++++++++++++-
packages/gql/src/generated/types.gd | 191 +++++++++++++++++-
packages/gql/src/generated/types.ts | 121 +++++++++++-
5 files changed, 939 insertions(+), 4 deletions(-)
diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt
index b0f91981..3a0d4987 100644
--- a/packages/gql/src/generated/Types.kt
+++ b/packages/gql/src/generated/Types.kt
@@ -668,6 +668,47 @@ public enum class ProductQueryType(val rawValue: String) {
fun toJson(): String = rawValue
}
+/**
+ * Status code for individual products returned from queryProductDetailsAsync (Android)
+ * Prior to 8.0, products that couldn't be fetched were simply not returned.
+ * With 8.0+, these products are returned with a status code explaining why.
+ * Available in Google Play Billing Library 8.0.0+
+ */
+public enum class ProductStatusAndroid(val rawValue: String) {
+ /**
+ * Product was successfully fetched
+ */
+ Ok("ok"),
+ /**
+ * Product not found - the SKU doesn't exist in the Play Console
+ */
+ NotFound("not-found"),
+ /**
+ * No offers available for the user - product exists but user is not eligible for any offers
+ */
+ NoOffersAvailable("no-offers-available"),
+ /**
+ * Unknown error occurred while fetching the product
+ */
+ Unknown("unknown")
+
+ companion object {
+ fun fromJson(value: String): ProductStatusAndroid = when (value) {
+ "ok" -> ProductStatusAndroid.Ok
+ "OK" -> ProductStatusAndroid.Ok
+ "not-found" -> ProductStatusAndroid.NotFound
+ "NOT_FOUND" -> ProductStatusAndroid.NotFound
+ "no-offers-available" -> ProductStatusAndroid.NoOffersAvailable
+ "NO_OFFERS_AVAILABLE" -> ProductStatusAndroid.NoOffersAvailable
+ "unknown" -> ProductStatusAndroid.Unknown
+ "UNKNOWN" -> ProductStatusAndroid.Unknown
+ else -> throw IllegalArgumentException("Unknown ProductStatusAndroid value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
public enum class ProductType(val rawValue: String) {
InApp("in-app"),
Subs("subs")
@@ -752,9 +793,47 @@ public enum class PurchaseVerificationProvider(val rawValue: String) {
fun toJson(): String = rawValue
}
+/**
+ * Sub-response codes for more granular purchase error information (Android)
+ * Available in Google Play Billing Library 8.0.0+
+ */
+public enum class SubResponseCodeAndroid(val rawValue: String) {
+ /**
+ * No specific sub-response code applies
+ */
+ NoApplicableSubResponseCode("no-applicable-sub-response-code"),
+ /**
+ * User's payment method has insufficient funds
+ */
+ PaymentDeclinedDueToInsufficientFunds("payment-declined-due-to-insufficient-funds"),
+ /**
+ * User doesn't meet subscription offer eligibility requirements
+ */
+ UserIneligible("user-ineligible")
+
+ companion object {
+ fun fromJson(value: String): SubResponseCodeAndroid = when (value) {
+ "no-applicable-sub-response-code" -> SubResponseCodeAndroid.NoApplicableSubResponseCode
+ "NO_APPLICABLE_SUB_RESPONSE_CODE" -> SubResponseCodeAndroid.NoApplicableSubResponseCode
+ "payment-declined-due-to-insufficient-funds" -> SubResponseCodeAndroid.PaymentDeclinedDueToInsufficientFunds
+ "PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS" -> SubResponseCodeAndroid.PaymentDeclinedDueToInsufficientFunds
+ "user-ineligible" -> SubResponseCodeAndroid.UserIneligible
+ "USER_INELIGIBLE" -> SubResponseCodeAndroid.UserIneligible
+ else -> throw IllegalArgumentException("Unknown SubResponseCodeAndroid value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
public enum class SubscriptionOfferTypeIOS(val rawValue: String) {
Introductory("introductory"),
- Promotional("promotional")
+ Promotional("promotional"),
+ /**
+ * Win-back offer type (iOS 18+)
+ * Used to re-engage churned subscribers with a discount or free trial.
+ */
+ WinBack("win-back")
companion object {
fun fromJson(value: String): SubscriptionOfferTypeIOS = when (value) {
@@ -764,6 +843,9 @@ public enum class SubscriptionOfferTypeIOS(val rawValue: String) {
"promotional" -> SubscriptionOfferTypeIOS.Promotional
"PROMOTIONAL" -> SubscriptionOfferTypeIOS.Promotional
"Promotional" -> SubscriptionOfferTypeIOS.Promotional
+ "win-back" -> SubscriptionOfferTypeIOS.WinBack
+ "WIN_BACK" -> SubscriptionOfferTypeIOS.WinBack
+ "WinBack" -> SubscriptionOfferTypeIOS.WinBack
else -> throw IllegalArgumentException("Unknown SubscriptionOfferTypeIOS value: $value")
}
}
@@ -1130,6 +1212,44 @@ public data class BillingProgramReportingDetailsAndroid(
)
}
+/**
+ * Extended billing result with sub-response code (Android)
+ * Available in Google Play Billing Library 8.0.0+
+ */
+public data class BillingResultAndroid(
+ /**
+ * Debug message from the billing library
+ */
+ val debugMessage: String? = null,
+ /**
+ * The response code from the billing operation
+ */
+ val responseCode: Int,
+ /**
+ * Sub-response code for more granular error information (8.0+).
+ * Provides additional context when responseCode indicates an error.
+ */
+ val subResponseCode: SubResponseCodeAndroid? = null
+) {
+
+ companion object {
+ fun fromJson(json: Map): BillingResultAndroid {
+ return BillingResultAndroid(
+ debugMessage = json["debugMessage"] as? String,
+ responseCode = (json["responseCode"] as? Number)?.toInt() ?: 0,
+ subResponseCode = (json["subResponseCode"] as? String)?.let { SubResponseCodeAndroid.fromJson(it) },
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "__typename" to "BillingResultAndroid",
+ "debugMessage" to debugMessage,
+ "responseCode" to responseCode,
+ "subResponseCode" to subResponseCode?.toJson(),
+ )
+}
+
/**
* Details provided when user selects developer billing option (Android)
* Received via DeveloperProvidedBillingListener callback
@@ -1721,6 +1841,14 @@ public data class ProductAndroid(
val oneTimePurchaseOfferDetailsAndroid: List? = null,
override val platform: IapPlatform = IapPlatform.Android,
override val price: Double? = null,
+ /**
+ * Product-level status code indicating fetch result (Android 8.0+)
+ * OK = product fetched successfully
+ * NOT_FOUND = SKU doesn't exist
+ * NO_OFFERS_AVAILABLE = user not eligible for any offers
+ * Available in Google Play Billing Library 8.0.0+
+ */
+ val productStatusAndroid: ProductStatusAndroid? = null,
/**
* @deprecated Use subscriptionOffers instead for cross-platform compatibility.
*/
@@ -1749,6 +1877,7 @@ public data class ProductAndroid(
oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductAndroidOneTimePurchaseOfferDetail.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductAndroidOneTimePurchaseOfferDetail") },
platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
price = (json["price"] as? Number)?.toDouble(),
+ productStatusAndroid = (json["productStatusAndroid"] as? String)?.let { ProductStatusAndroid.fromJson(it) },
subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductSubscriptionAndroidOfferDetails.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductSubscriptionAndroidOfferDetails") },
subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") },
title = json["title"] as? String ?: "",
@@ -1770,6 +1899,7 @@ public data class ProductAndroid(
"oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseOfferDetailsAndroid?.map { it.toJson() },
"platform" to platform.toJson(),
"price" to price,
+ "productStatusAndroid" to productStatusAndroid?.toJson(),
"subscriptionOfferDetailsAndroid" to subscriptionOfferDetailsAndroid?.map { it.toJson() },
"subscriptionOffers" to subscriptionOffers?.map { it.toJson() },
"title" to title,
@@ -1958,6 +2088,14 @@ public data class ProductSubscriptionAndroid(
val oneTimePurchaseOfferDetailsAndroid: List? = null,
override val platform: IapPlatform = IapPlatform.Android,
override val price: Double? = null,
+ /**
+ * Product-level status code indicating fetch result (Android 8.0+)
+ * OK = product fetched successfully
+ * NOT_FOUND = SKU doesn't exist
+ * NO_OFFERS_AVAILABLE = user not eligible for any offers
+ * Available in Google Play Billing Library 8.0.0+
+ */
+ val productStatusAndroid: ProductStatusAndroid? = null,
/**
* @deprecated Use subscriptionOffers instead for cross-platform compatibility.
*/
@@ -1986,6 +2124,7 @@ public data class ProductSubscriptionAndroid(
oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductAndroidOneTimePurchaseOfferDetail.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductAndroidOneTimePurchaseOfferDetail") },
platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
price = (json["price"] as? Number)?.toDouble(),
+ productStatusAndroid = (json["productStatusAndroid"] as? String)?.let { ProductStatusAndroid.fromJson(it) },
subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductSubscriptionAndroidOfferDetails.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductSubscriptionAndroidOfferDetails") } ?: emptyList(),
subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") } ?: emptyList(),
title = json["title"] as? String ?: "",
@@ -2007,6 +2146,7 @@ public data class ProductSubscriptionAndroid(
"oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseOfferDetailsAndroid?.map { it.toJson() },
"platform" to platform.toJson(),
"price" to price,
+ "productStatusAndroid" to productStatusAndroid?.toJson(),
"subscriptionOfferDetailsAndroid" to subscriptionOfferDetailsAndroid.map { it.toJson() },
"subscriptionOffers" to subscriptionOffers.map { it.toJson() },
"title" to title,
@@ -3340,6 +3480,39 @@ public data class ProductRequest(
)
}
+/**
+ * JWS promotional offer input for iOS 15+ (StoreKit 2, WWDC 2025).
+ * New signature format using compact JWS string for promotional offers.
+ * This provides a simpler alternative to the legacy signature-based promotional offers.
+ * Back-deployed to iOS 15.
+ */
+public data class PromotionalOfferJWSInputIOS(
+ /**
+ * Compact JWS string signed by your server.
+ * The JWS should contain the promotional offer signature data.
+ * Format: header.payload.signature (base64url encoded)
+ */
+ val jws: String,
+ /**
+ * The promotional offer identifier from App Store Connect
+ */
+ val offerId: String
+) {
+ companion object {
+ fun fromJson(json: Map): PromotionalOfferJWSInputIOS {
+ return PromotionalOfferJWSInputIOS(
+ jws = json["jws"] as? String ?: "",
+ offerId = json["offerId"] as? String ?: "",
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "jws" to jws,
+ "offerId" to offerId,
+ )
+}
+
public typealias PurchaseInput = Purchase
public data class PurchaseOptions(
@@ -3347,6 +3520,13 @@ public data class PurchaseOptions(
* Also emit results through the iOS event listeners
*/
val alsoPublishToEventListenerIOS: Boolean? = null,
+ /**
+ * Include suspended subscriptions in the result (Android 8.1+).
+ * Suspended subscriptions have isSuspendedAndroid=true and should NOT be granted entitlements.
+ * Users should be directed to the subscription center to resolve payment issues.
+ * Default: false (only active subscriptions are returned)
+ */
+ val includeSuspendedAndroid: Boolean? = null,
/**
* Limit to currently active items on iOS
*/
@@ -3356,6 +3536,7 @@ public data class PurchaseOptions(
fun fromJson(json: Map): PurchaseOptions {
return PurchaseOptions(
alsoPublishToEventListenerIOS = json["alsoPublishToEventListenerIOS"] as? Boolean,
+ includeSuspendedAndroid = json["includeSuspendedAndroid"] as? Boolean,
onlyIncludeActiveItemsIOS = json["onlyIncludeActiveItemsIOS"] as? Boolean,
)
}
@@ -3363,6 +3544,7 @@ public data class PurchaseOptions(
fun toJson(): Map = mapOf(
"alsoPublishToEventListenerIOS" to alsoPublishToEventListenerIOS,
+ "includeSuspendedAndroid" to includeSuspendedAndroid,
"onlyIncludeActiveItemsIOS" to onlyIncludeActiveItemsIOS,
)
}
@@ -3428,6 +3610,19 @@ public data class RequestPurchaseIosProps(
* App account token for user tracking
*/
val appAccountToken: String? = null,
+ /**
+ * Override introductory offer eligibility (iOS 15+, WWDC 2025).
+ * Set to true to indicate the user is eligible for introductory offer,
+ * or false to indicate they are not. When nil, the system determines eligibility.
+ * Back-deployed to iOS 15.
+ */
+ val introductoryOfferEligibility: Boolean? = null,
+ /**
+ * JWS promotional offer (iOS 15+, WWDC 2025).
+ * New signature format using compact JWS string for promotional offers.
+ * Back-deployed to iOS 15.
+ */
+ val promotionalOfferJWS: PromotionalOfferJWSInputIOS? = null,
/**
* Purchase quantity
*/
@@ -3436,6 +3631,12 @@ public data class RequestPurchaseIosProps(
* Product SKU
*/
val sku: String,
+ /**
+ * Win-back offer to apply (iOS 18+)
+ * Used to re-engage churned subscribers with a discount or free trial.
+ * Note: Win-back offers only apply to subscription products.
+ */
+ val winBackOffer: WinBackOfferInputIOS? = null,
/**
* Discount offer to apply
*/
@@ -3447,8 +3648,11 @@ public data class RequestPurchaseIosProps(
advancedCommerceData = json["advancedCommerceData"] as? String,
andDangerouslyFinishTransactionAutomatically = json["andDangerouslyFinishTransactionAutomatically"] as? Boolean,
appAccountToken = json["appAccountToken"] as? String,
+ introductoryOfferEligibility = json["introductoryOfferEligibility"] as? Boolean,
+ promotionalOfferJWS = (json["promotionalOfferJWS"] as? Map)?.let { PromotionalOfferJWSInputIOS.fromJson(it) },
quantity = (json["quantity"] as? Number)?.toInt(),
sku = json["sku"] as? String ?: "",
+ winBackOffer = (json["winBackOffer"] as? Map)?.let { WinBackOfferInputIOS.fromJson(it) },
withOffer = (json["withOffer"] as? Map)?.let { DiscountOfferInputIOS.fromJson(it) },
)
}
@@ -3458,8 +3662,11 @@ public data class RequestPurchaseIosProps(
"advancedCommerceData" to advancedCommerceData,
"andDangerouslyFinishTransactionAutomatically" to andDangerouslyFinishTransactionAutomatically,
"appAccountToken" to appAccountToken,
+ "introductoryOfferEligibility" to introductoryOfferEligibility,
+ "promotionalOfferJWS" to promotionalOfferJWS?.toJson(),
"quantity" to quantity,
"sku" to sku,
+ "winBackOffer" to winBackOffer?.toJson(),
"withOffer" to withOffer?.toJson(),
)
}
@@ -3643,8 +3850,28 @@ public data class RequestSubscriptionIosProps(
val advancedCommerceData: String? = null,
val andDangerouslyFinishTransactionAutomatically: Boolean? = null,
val appAccountToken: String? = null,
+ /**
+ * Override introductory offer eligibility (iOS 15+, WWDC 2025).
+ * Set to true to indicate the user is eligible for introductory offer,
+ * or false to indicate they are not. When nil, the system determines eligibility.
+ * Back-deployed to iOS 15.
+ */
+ val introductoryOfferEligibility: Boolean? = null,
+ /**
+ * JWS promotional offer (iOS 15+, WWDC 2025).
+ * New signature format using compact JWS string for promotional offers.
+ * Back-deployed to iOS 15.
+ */
+ val promotionalOfferJWS: PromotionalOfferJWSInputIOS? = null,
val quantity: Int? = null,
val sku: String,
+ /**
+ * Win-back offer to apply (iOS 18+)
+ * Used to re-engage churned subscribers with a discount or free trial.
+ * The offer is available when the customer is eligible and can be discovered
+ * via StoreKit Message (automatic) or subscription offer APIs.
+ */
+ val winBackOffer: WinBackOfferInputIOS? = null,
val withOffer: DiscountOfferInputIOS? = null
) {
companion object {
@@ -3653,8 +3880,11 @@ public data class RequestSubscriptionIosProps(
advancedCommerceData = json["advancedCommerceData"] as? String,
andDangerouslyFinishTransactionAutomatically = json["andDangerouslyFinishTransactionAutomatically"] as? Boolean,
appAccountToken = json["appAccountToken"] as? String,
+ introductoryOfferEligibility = json["introductoryOfferEligibility"] as? Boolean,
+ promotionalOfferJWS = (json["promotionalOfferJWS"] as? Map)?.let { PromotionalOfferJWSInputIOS.fromJson(it) },
quantity = (json["quantity"] as? Number)?.toInt(),
sku = json["sku"] as? String ?: "",
+ winBackOffer = (json["winBackOffer"] as? Map)?.let { WinBackOfferInputIOS.fromJson(it) },
withOffer = (json["withOffer"] as? Map)?.let { DiscountOfferInputIOS.fromJson(it) },
)
}
@@ -3664,8 +3894,11 @@ public data class RequestSubscriptionIosProps(
"advancedCommerceData" to advancedCommerceData,
"andDangerouslyFinishTransactionAutomatically" to andDangerouslyFinishTransactionAutomatically,
"appAccountToken" to appAccountToken,
+ "introductoryOfferEligibility" to introductoryOfferEligibility,
+ "promotionalOfferJWS" to promotionalOfferJWS?.toJson(),
"quantity" to quantity,
"sku" to sku,
+ "winBackOffer" to winBackOffer?.toJson(),
"withOffer" to withOffer?.toJson(),
)
}
@@ -3990,6 +4223,31 @@ public data class VerifyPurchaseWithProviderProps(
)
}
+/**
+ * Win-back offer input for iOS 18+ (StoreKit 2)
+ * Win-back offers are used to re-engage churned subscribers.
+ * The offer is automatically presented via StoreKit Message when eligible,
+ * or can be applied programmatically during purchase.
+ */
+public data class WinBackOfferInputIOS(
+ /**
+ * The win-back offer ID from App Store Connect
+ */
+ val offerId: String
+) {
+ companion object {
+ fun fromJson(json: Map): WinBackOfferInputIOS {
+ return WinBackOfferInputIOS(
+ offerId = json["offerId"] as? String ?: "",
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "offerId" to offerId,
+ )
+}
+
// MARK: - Unions
public sealed interface Product : ProductCommon {
diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift
index 656e0b20..f176fbf0 100644
--- a/packages/gql/src/generated/Types.swift
+++ b/packages/gql/src/generated/Types.swift
@@ -299,6 +299,21 @@ public enum ProductQueryType: String, Codable, CaseIterable {
case all = "all"
}
+/// Status code for individual products returned from queryProductDetailsAsync (Android)
+/// Prior to 8.0, products that couldn't be fetched were simply not returned.
+/// With 8.0+, these products are returned with a status code explaining why.
+/// Available in Google Play Billing Library 8.0.0+
+public enum ProductStatusAndroid: String, Codable, CaseIterable {
+ /// Product was successfully fetched
+ case ok = "ok"
+ /// Product not found - the SKU doesn't exist in the Play Console
+ case notFound = "not-found"
+ /// No offers available for the user - product exists but user is not eligible for any offers
+ case noOffersAvailable = "no-offers-available"
+ /// Unknown error occurred while fetching the product
+ case unknown = "unknown"
+}
+
public enum ProductType: String, Codable, CaseIterable {
case inApp = "in-app"
case subs = "subs"
@@ -321,9 +336,23 @@ public enum PurchaseVerificationProvider: String, Codable, CaseIterable {
case iapkit = "iapkit"
}
+/// Sub-response codes for more granular purchase error information (Android)
+/// Available in Google Play Billing Library 8.0.0+
+public enum SubResponseCodeAndroid: String, Codable, CaseIterable {
+ /// No specific sub-response code applies
+ case noApplicableSubResponseCode = "no-applicable-sub-response-code"
+ /// User's payment method has insufficient funds
+ case paymentDeclinedDueToInsufficientFunds = "payment-declined-due-to-insufficient-funds"
+ /// User doesn't meet subscription offer eligibility requirements
+ case userIneligible = "user-ineligible"
+}
+
public enum SubscriptionOfferTypeIOS: String, Codable, CaseIterable {
case introductory = "introductory"
case promotional = "promotional"
+ /// Win-back offer type (iOS 18+)
+ /// Used to re-engage churned subscribers with a discount or free trial.
+ case winBack = "win-back"
}
public enum SubscriptionPeriodIOS: String, Codable, CaseIterable {
@@ -463,6 +492,18 @@ public struct BillingProgramReportingDetailsAndroid: Codable {
public var externalTransactionToken: String
}
+/// Extended billing result with sub-response code (Android)
+/// Available in Google Play Billing Library 8.0.0+
+public struct BillingResultAndroid: Codable {
+ /// Debug message from the billing library
+ public var debugMessage: String?
+ /// The response code from the billing operation
+ public var responseCode: Int
+ /// Sub-response code for more granular error information (8.0+).
+ /// Provides additional context when responseCode indicates an error.
+ public var subResponseCode: SubResponseCodeAndroid?
+}
+
/// Details provided when user selects developer billing option (Android)
/// Received via DeveloperProvidedBillingListener callback
/// Available in Google Play Billing Library 8.3.0+
@@ -668,6 +709,12 @@ public struct ProductAndroid: Codable, ProductCommon {
public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]?
public var platform: IapPlatform = .android
public var price: Double?
+ /// Product-level status code indicating fetch result (Android 8.0+)
+ /// OK = product fetched successfully
+ /// NOT_FOUND = SKU doesn't exist
+ /// NO_OFFERS_AVAILABLE = user not eligible for any offers
+ /// Available in Google Play Billing Library 8.0.0+
+ public var productStatusAndroid: ProductStatusAndroid?
/// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
public var subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails]?
/// Standardized subscription offers.
@@ -751,6 +798,12 @@ public struct ProductSubscriptionAndroid: Codable, ProductCommon {
public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]?
public var platform: IapPlatform = .android
public var price: Double?
+ /// Product-level status code indicating fetch result (Android 8.0+)
+ /// OK = product fetched successfully
+ /// NOT_FOUND = SKU doesn't exist
+ /// NO_OFFERS_AVAILABLE = user not eligible for any offers
+ /// Available in Google Play Billing Library 8.0.0+
+ public var productStatusAndroid: ProductStatusAndroid?
/// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
public var subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails]
/// Standardized subscription offers.
@@ -1280,19 +1333,47 @@ public struct ProductRequest: Codable {
}
}
+/// JWS promotional offer input for iOS 15+ (StoreKit 2, WWDC 2025).
+/// New signature format using compact JWS string for promotional offers.
+/// This provides a simpler alternative to the legacy signature-based promotional offers.
+/// Back-deployed to iOS 15.
+public struct PromotionalOfferJWSInputIOS: Codable {
+ /// Compact JWS string signed by your server.
+ /// The JWS should contain the promotional offer signature data.
+ /// Format: header.payload.signature (base64url encoded)
+ public var jws: String
+ /// The promotional offer identifier from App Store Connect
+ public var offerId: String
+
+ public init(
+ jws: String,
+ offerId: String
+ ) {
+ self.jws = jws
+ self.offerId = offerId
+ }
+}
+
public typealias PurchaseInput = Purchase
public struct PurchaseOptions: Codable {
/// Also emit results through the iOS event listeners
public var alsoPublishToEventListenerIOS: Bool?
+ /// Include suspended subscriptions in the result (Android 8.1+).
+ /// Suspended subscriptions have isSuspendedAndroid=true and should NOT be granted entitlements.
+ /// Users should be directed to the subscription center to resolve payment issues.
+ /// Default: false (only active subscriptions are returned)
+ public var includeSuspendedAndroid: Bool?
/// Limit to currently active items on iOS
public var onlyIncludeActiveItemsIOS: Bool?
public init(
alsoPublishToEventListenerIOS: Bool? = nil,
+ includeSuspendedAndroid: Bool? = nil,
onlyIncludeActiveItemsIOS: Bool? = nil
) {
self.alsoPublishToEventListenerIOS = alsoPublishToEventListenerIOS
+ self.includeSuspendedAndroid = includeSuspendedAndroid
self.onlyIncludeActiveItemsIOS = onlyIncludeActiveItemsIOS
}
}
@@ -1336,10 +1417,23 @@ public struct RequestPurchaseIosProps: Codable {
public var andDangerouslyFinishTransactionAutomatically: Bool?
/// App account token for user tracking
public var appAccountToken: String?
+ /// Override introductory offer eligibility (iOS 15+, WWDC 2025).
+ /// Set to true to indicate the user is eligible for introductory offer,
+ /// or false to indicate they are not. When nil, the system determines eligibility.
+ /// Back-deployed to iOS 15.
+ public var introductoryOfferEligibility: Bool?
+ /// JWS promotional offer (iOS 15+, WWDC 2025).
+ /// New signature format using compact JWS string for promotional offers.
+ /// Back-deployed to iOS 15.
+ public var promotionalOfferJWS: PromotionalOfferJWSInputIOS?
/// Purchase quantity
public var quantity: Int?
/// Product SKU
public var sku: String
+ /// Win-back offer to apply (iOS 18+)
+ /// Used to re-engage churned subscribers with a discount or free trial.
+ /// Note: Win-back offers only apply to subscription products.
+ public var winBackOffer: WinBackOfferInputIOS?
/// Discount offer to apply
public var withOffer: DiscountOfferInputIOS?
@@ -1347,15 +1441,21 @@ public struct RequestPurchaseIosProps: Codable {
advancedCommerceData: String? = nil,
andDangerouslyFinishTransactionAutomatically: Bool? = nil,
appAccountToken: String? = nil,
+ introductoryOfferEligibility: Bool? = nil,
+ promotionalOfferJWS: PromotionalOfferJWSInputIOS? = nil,
quantity: Int? = nil,
sku: String,
+ winBackOffer: WinBackOfferInputIOS? = nil,
withOffer: DiscountOfferInputIOS? = nil
) {
self.advancedCommerceData = advancedCommerceData
self.andDangerouslyFinishTransactionAutomatically = andDangerouslyFinishTransactionAutomatically
self.appAccountToken = appAccountToken
+ self.introductoryOfferEligibility = introductoryOfferEligibility
+ self.promotionalOfferJWS = promotionalOfferJWS
self.quantity = quantity
self.sku = sku
+ self.winBackOffer = winBackOffer
self.withOffer = withOffer
}
}
@@ -1514,23 +1614,43 @@ public struct RequestSubscriptionIosProps: Codable {
public var advancedCommerceData: String?
public var andDangerouslyFinishTransactionAutomatically: Bool?
public var appAccountToken: String?
+ /// Override introductory offer eligibility (iOS 15+, WWDC 2025).
+ /// Set to true to indicate the user is eligible for introductory offer,
+ /// or false to indicate they are not. When nil, the system determines eligibility.
+ /// Back-deployed to iOS 15.
+ public var introductoryOfferEligibility: Bool?
+ /// JWS promotional offer (iOS 15+, WWDC 2025).
+ /// New signature format using compact JWS string for promotional offers.
+ /// Back-deployed to iOS 15.
+ public var promotionalOfferJWS: PromotionalOfferJWSInputIOS?
public var quantity: Int?
public var sku: String
+ /// Win-back offer to apply (iOS 18+)
+ /// Used to re-engage churned subscribers with a discount or free trial.
+ /// The offer is available when the customer is eligible and can be discovered
+ /// via StoreKit Message (automatic) or subscription offer APIs.
+ public var winBackOffer: WinBackOfferInputIOS?
public var withOffer: DiscountOfferInputIOS?
public init(
advancedCommerceData: String? = nil,
andDangerouslyFinishTransactionAutomatically: Bool? = nil,
appAccountToken: String? = nil,
+ introductoryOfferEligibility: Bool? = nil,
+ promotionalOfferJWS: PromotionalOfferJWSInputIOS? = nil,
quantity: Int? = nil,
sku: String,
+ winBackOffer: WinBackOfferInputIOS? = nil,
withOffer: DiscountOfferInputIOS? = nil
) {
self.advancedCommerceData = advancedCommerceData
self.andDangerouslyFinishTransactionAutomatically = andDangerouslyFinishTransactionAutomatically
self.appAccountToken = appAccountToken
+ self.introductoryOfferEligibility = introductoryOfferEligibility
+ self.promotionalOfferJWS = promotionalOfferJWS
self.quantity = quantity
self.sku = sku
+ self.winBackOffer = winBackOffer
self.withOffer = withOffer
}
}
@@ -1735,6 +1855,21 @@ public struct VerifyPurchaseWithProviderProps: Codable {
}
}
+/// Win-back offer input for iOS 18+ (StoreKit 2)
+/// Win-back offers are used to re-engage churned subscribers.
+/// The offer is automatically presented via StoreKit Message when eligible,
+/// or can be applied programmatically during purchase.
+public struct WinBackOfferInputIOS: Codable {
+ /// The win-back offer ID from App Store Connect
+ public var offerId: String
+
+ public init(
+ offerId: String
+ ) {
+ self.offerId = offerId
+ }
+}
+
// MARK: - Unions
public enum Product: Codable, ProductCommon {
diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart
index 7c8c0201..78c49cec 100644
--- a/packages/gql/src/generated/types.dart
+++ b/packages/gql/src/generated/types.dart
@@ -577,6 +577,41 @@ enum ProductQueryType {
String toJson() => value;
}
+/// Status code for individual products returned from queryProductDetailsAsync (Android)
+/// Prior to 8.0, products that couldn't be fetched were simply not returned.
+/// With 8.0+, these products are returned with a status code explaining why.
+/// Available in Google Play Billing Library 8.0.0+
+enum ProductStatusAndroid {
+ /// Product was successfully fetched
+ Ok('ok'),
+ /// Product not found - the SKU doesn't exist in the Play Console
+ NotFound('not-found'),
+ /// No offers available for the user - product exists but user is not eligible for any offers
+ NoOffersAvailable('no-offers-available'),
+ /// Unknown error occurred while fetching the product
+ Unknown('unknown');
+
+ const ProductStatusAndroid(this.value);
+ final String value;
+
+ factory ProductStatusAndroid.fromJson(String value) {
+ final normalized = value.toLowerCase().replaceAll('_', '-');
+ switch (normalized) {
+ case 'ok':
+ return ProductStatusAndroid.Ok;
+ case 'not-found':
+ return ProductStatusAndroid.NotFound;
+ case 'no-offers-available':
+ return ProductStatusAndroid.NoOffersAvailable;
+ case 'unknown':
+ return ProductStatusAndroid.Unknown;
+ }
+ throw ArgumentError('Unknown ProductStatusAndroid value: $value');
+ }
+
+ String toJson() => value;
+}
+
enum ProductType {
InApp('in-app'),
Subs('subs');
@@ -667,9 +702,41 @@ enum PurchaseVerificationProvider {
String toJson() => value;
}
+/// Sub-response codes for more granular purchase error information (Android)
+/// Available in Google Play Billing Library 8.0.0+
+enum SubResponseCodeAndroid {
+ /// No specific sub-response code applies
+ NoApplicableSubResponseCode('no-applicable-sub-response-code'),
+ /// User's payment method has insufficient funds
+ PaymentDeclinedDueToInsufficientFunds('payment-declined-due-to-insufficient-funds'),
+ /// User doesn't meet subscription offer eligibility requirements
+ UserIneligible('user-ineligible');
+
+ const SubResponseCodeAndroid(this.value);
+ final String value;
+
+ factory SubResponseCodeAndroid.fromJson(String value) {
+ final normalized = value.toLowerCase().replaceAll('_', '-');
+ switch (normalized) {
+ case 'no-applicable-sub-response-code':
+ return SubResponseCodeAndroid.NoApplicableSubResponseCode;
+ case 'payment-declined-due-to-insufficient-funds':
+ return SubResponseCodeAndroid.PaymentDeclinedDueToInsufficientFunds;
+ case 'user-ineligible':
+ return SubResponseCodeAndroid.UserIneligible;
+ }
+ throw ArgumentError('Unknown SubResponseCodeAndroid value: $value');
+ }
+
+ String toJson() => value;
+}
+
enum SubscriptionOfferTypeIOS {
Introductory('introductory'),
- Promotional('promotional');
+ Promotional('promotional'),
+ /// Win-back offer type (iOS 18+)
+ /// Used to re-engage churned subscribers with a discount or free trial.
+ WinBack('win-back');
const SubscriptionOfferTypeIOS(this.value);
final String value;
@@ -681,6 +748,8 @@ enum SubscriptionOfferTypeIOS {
return SubscriptionOfferTypeIOS.Introductory;
case 'promotional':
return SubscriptionOfferTypeIOS.Promotional;
+ case 'win-back':
+ return SubscriptionOfferTypeIOS.WinBack;
}
throw ArgumentError('Unknown SubscriptionOfferTypeIOS value: $value');
}
@@ -1044,6 +1113,41 @@ class BillingProgramReportingDetailsAndroid {
}
}
+/// Extended billing result with sub-response code (Android)
+/// Available in Google Play Billing Library 8.0.0+
+class BillingResultAndroid {
+ const BillingResultAndroid({
+ this.debugMessage,
+ required this.responseCode,
+ this.subResponseCode,
+ });
+
+ /// Debug message from the billing library
+ final String? debugMessage;
+ /// The response code from the billing operation
+ final int responseCode;
+ /// Sub-response code for more granular error information (8.0+).
+ /// Provides additional context when responseCode indicates an error.
+ final SubResponseCodeAndroid? subResponseCode;
+
+ factory BillingResultAndroid.fromJson(Map json) {
+ return BillingResultAndroid(
+ debugMessage: json['debugMessage'] as String?,
+ responseCode: json['responseCode'] as int,
+ subResponseCode: json['subResponseCode'] != null ? SubResponseCodeAndroid.fromJson(json['subResponseCode'] as String) : null,
+ );
+ }
+
+ Map toJson() {
+ return {
+ '__typename': 'BillingResultAndroid',
+ 'debugMessage': debugMessage,
+ 'responseCode': responseCode,
+ 'subResponseCode': subResponseCode?.toJson(),
+ };
+ }
+}
+
/// Details provided when user selects developer billing option (Android)
/// Received via DeveloperProvidedBillingListener callback
/// Available in Google Play Billing Library 8.3.0+
@@ -1626,6 +1730,7 @@ class ProductAndroid extends Product implements ProductCommon {
this.oneTimePurchaseOfferDetailsAndroid,
this.platform = IapPlatform.Android,
this.price,
+ this.productStatusAndroid,
this.subscriptionOfferDetailsAndroid,
this.subscriptionOffers,
required this.title,
@@ -1649,6 +1754,12 @@ class ProductAndroid extends Product implements ProductCommon {
final List? oneTimePurchaseOfferDetailsAndroid;
final IapPlatform platform;
final double? price;
+ /// Product-level status code indicating fetch result (Android 8.0+)
+ /// OK = product fetched successfully
+ /// NOT_FOUND = SKU doesn't exist
+ /// NO_OFFERS_AVAILABLE = user not eligible for any offers
+ /// Available in Google Play Billing Library 8.0.0+
+ final ProductStatusAndroid? productStatusAndroid;
/// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
final List? subscriptionOfferDetailsAndroid;
/// Standardized subscription offers.
@@ -1671,6 +1782,7 @@ class ProductAndroid extends Product implements ProductCommon {
oneTimePurchaseOfferDetailsAndroid: (json['oneTimePurchaseOfferDetailsAndroid'] as List?) == null ? null : (json['oneTimePurchaseOfferDetailsAndroid'] as List?)!.map((e) => ProductAndroidOneTimePurchaseOfferDetail.fromJson(e as Map)).toList(),
platform: IapPlatform.fromJson(json['platform'] as String),
price: (json['price'] as num?)?.toDouble(),
+ productStatusAndroid: json['productStatusAndroid'] != null ? ProductStatusAndroid.fromJson(json['productStatusAndroid'] as String) : null,
subscriptionOfferDetailsAndroid: (json['subscriptionOfferDetailsAndroid'] as List?) == null ? null : (json['subscriptionOfferDetailsAndroid'] as List?)!.map((e) => ProductSubscriptionAndroidOfferDetails.fromJson(e as Map)).toList(),
subscriptionOffers: (json['subscriptionOffers'] as List?) == null ? null : (json['subscriptionOffers'] as List?)!.map((e) => SubscriptionOffer.fromJson(e as Map)).toList(),
title: json['title'] as String,
@@ -1693,6 +1805,7 @@ class ProductAndroid extends Product implements ProductCommon {
'oneTimePurchaseOfferDetailsAndroid': oneTimePurchaseOfferDetailsAndroid == null ? null : oneTimePurchaseOfferDetailsAndroid!.map((e) => e.toJson()).toList(),
'platform': platform.toJson(),
'price': price,
+ 'productStatusAndroid': productStatusAndroid?.toJson(),
'subscriptionOfferDetailsAndroid': subscriptionOfferDetailsAndroid == null ? null : subscriptionOfferDetailsAndroid!.map((e) => e.toJson()).toList(),
'subscriptionOffers': subscriptionOffers == null ? null : subscriptionOffers!.map((e) => e.toJson()).toList(),
'title': title,
@@ -1882,6 +1995,7 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC
this.oneTimePurchaseOfferDetailsAndroid,
this.platform = IapPlatform.Android,
this.price,
+ this.productStatusAndroid,
required this.subscriptionOfferDetailsAndroid,
required this.subscriptionOffers,
required this.title,
@@ -1905,6 +2019,12 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC
final List? oneTimePurchaseOfferDetailsAndroid;
final IapPlatform platform;
final double? price;
+ /// Product-level status code indicating fetch result (Android 8.0+)
+ /// OK = product fetched successfully
+ /// NOT_FOUND = SKU doesn't exist
+ /// NO_OFFERS_AVAILABLE = user not eligible for any offers
+ /// Available in Google Play Billing Library 8.0.0+
+ final ProductStatusAndroid? productStatusAndroid;
/// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
final List subscriptionOfferDetailsAndroid;
/// Standardized subscription offers.
@@ -1927,6 +2047,7 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC
oneTimePurchaseOfferDetailsAndroid: (json['oneTimePurchaseOfferDetailsAndroid'] as List?) == null ? null : (json['oneTimePurchaseOfferDetailsAndroid'] as List?)!.map((e) => ProductAndroidOneTimePurchaseOfferDetail.fromJson(e as Map)).toList(),
platform: IapPlatform.fromJson(json['platform'] as String),
price: (json['price'] as num?)?.toDouble(),
+ productStatusAndroid: json['productStatusAndroid'] != null ? ProductStatusAndroid.fromJson(json['productStatusAndroid'] as String) : null,
subscriptionOfferDetailsAndroid: (json['subscriptionOfferDetailsAndroid'] as List).map((e) => ProductSubscriptionAndroidOfferDetails.fromJson(e as Map)).toList(),
subscriptionOffers: (json['subscriptionOffers'] as List).map((e) => SubscriptionOffer.fromJson(e as Map)).toList(),
title: json['title'] as String,
@@ -1949,6 +2070,7 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC
'oneTimePurchaseOfferDetailsAndroid': oneTimePurchaseOfferDetailsAndroid == null ? null : oneTimePurchaseOfferDetailsAndroid!.map((e) => e.toJson()).toList(),
'platform': platform.toJson(),
'price': price,
+ 'productStatusAndroid': productStatusAndroid?.toJson(),
'subscriptionOfferDetailsAndroid': subscriptionOfferDetailsAndroid.map((e) => e.toJson()).toList(),
'subscriptionOffers': subscriptionOffers.map((e) => e.toJson()).toList(),
'title': title,
@@ -3408,22 +3530,61 @@ class ProductRequest {
}
}
+/// JWS promotional offer input for iOS 15+ (StoreKit 2, WWDC 2025).
+/// New signature format using compact JWS string for promotional offers.
+/// This provides a simpler alternative to the legacy signature-based promotional offers.
+/// Back-deployed to iOS 15.
+class PromotionalOfferJWSInputIOS {
+ const PromotionalOfferJWSInputIOS({
+ required this.jws,
+ required this.offerId,
+ });
+
+ /// Compact JWS string signed by your server.
+ /// The JWS should contain the promotional offer signature data.
+ /// Format: header.payload.signature (base64url encoded)
+ final String jws;
+ /// The promotional offer identifier from App Store Connect
+ final String offerId;
+
+ factory PromotionalOfferJWSInputIOS.fromJson(Map json) {
+ return PromotionalOfferJWSInputIOS(
+ jws: json['jws'] as String,
+ offerId: json['offerId'] as String,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'jws': jws,
+ 'offerId': offerId,
+ };
+ }
+}
+
typedef PurchaseInput = Purchase;
class PurchaseOptions {
const PurchaseOptions({
this.alsoPublishToEventListenerIOS,
+ this.includeSuspendedAndroid,
this.onlyIncludeActiveItemsIOS,
});
/// Also emit results through the iOS event listeners
final bool? alsoPublishToEventListenerIOS;
+ /// Include suspended subscriptions in the result (Android 8.1+).
+ /// Suspended subscriptions have isSuspendedAndroid=true and should NOT be granted entitlements.
+ /// Users should be directed to the subscription center to resolve payment issues.
+ /// Default: false (only active subscriptions are returned)
+ final bool? includeSuspendedAndroid;
/// Limit to currently active items on iOS
final bool? onlyIncludeActiveItemsIOS;
factory PurchaseOptions.fromJson(Map json) {
return PurchaseOptions(
alsoPublishToEventListenerIOS: json['alsoPublishToEventListenerIOS'] as bool?,
+ includeSuspendedAndroid: json['includeSuspendedAndroid'] as bool?,
onlyIncludeActiveItemsIOS: json['onlyIncludeActiveItemsIOS'] as bool?,
);
}
@@ -3431,6 +3592,7 @@ class PurchaseOptions {
Map toJson() {
return {
'alsoPublishToEventListenerIOS': alsoPublishToEventListenerIOS,
+ 'includeSuspendedAndroid': includeSuspendedAndroid,
'onlyIncludeActiveItemsIOS': onlyIncludeActiveItemsIOS,
};
}
@@ -3484,8 +3646,11 @@ class RequestPurchaseIosProps {
this.advancedCommerceData,
this.andDangerouslyFinishTransactionAutomatically,
this.appAccountToken,
+ this.introductoryOfferEligibility,
+ this.promotionalOfferJWS,
this.quantity,
required this.sku,
+ this.winBackOffer,
this.withOffer,
});
@@ -3498,10 +3663,23 @@ class RequestPurchaseIosProps {
final bool? andDangerouslyFinishTransactionAutomatically;
/// App account token for user tracking
final String? appAccountToken;
+ /// Override introductory offer eligibility (iOS 15+, WWDC 2025).
+ /// Set to true to indicate the user is eligible for introductory offer,
+ /// or false to indicate they are not. When nil, the system determines eligibility.
+ /// Back-deployed to iOS 15.
+ final bool? introductoryOfferEligibility;
+ /// JWS promotional offer (iOS 15+, WWDC 2025).
+ /// New signature format using compact JWS string for promotional offers.
+ /// Back-deployed to iOS 15.
+ final PromotionalOfferJWSInputIOS? promotionalOfferJWS;
/// Purchase quantity
final int? quantity;
/// Product SKU
final String sku;
+ /// Win-back offer to apply (iOS 18+)
+ /// Used to re-engage churned subscribers with a discount or free trial.
+ /// Note: Win-back offers only apply to subscription products.
+ final WinBackOfferInputIOS? winBackOffer;
/// Discount offer to apply
final DiscountOfferInputIOS? withOffer;
@@ -3510,8 +3688,11 @@ class RequestPurchaseIosProps {
advancedCommerceData: json['advancedCommerceData'] as String?,
andDangerouslyFinishTransactionAutomatically: json['andDangerouslyFinishTransactionAutomatically'] as bool?,
appAccountToken: json['appAccountToken'] as String?,
+ introductoryOfferEligibility: json['introductoryOfferEligibility'] as bool?,
+ promotionalOfferJWS: json['promotionalOfferJWS'] != null ? PromotionalOfferJWSInputIOS.fromJson(json['promotionalOfferJWS'] as Map) : null,
quantity: json['quantity'] as int?,
sku: json['sku'] as String,
+ winBackOffer: json['winBackOffer'] != null ? WinBackOfferInputIOS.fromJson(json['winBackOffer'] as Map) : null,
withOffer: json['withOffer'] != null ? DiscountOfferInputIOS.fromJson(json['withOffer'] as Map) : null,
);
}
@@ -3521,8 +3702,11 @@ class RequestPurchaseIosProps {
'advancedCommerceData': advancedCommerceData,
'andDangerouslyFinishTransactionAutomatically': andDangerouslyFinishTransactionAutomatically,
'appAccountToken': appAccountToken,
+ 'introductoryOfferEligibility': introductoryOfferEligibility,
+ 'promotionalOfferJWS': promotionalOfferJWS?.toJson(),
'quantity': quantity,
'sku': sku,
+ 'winBackOffer': winBackOffer?.toJson(),
'withOffer': withOffer?.toJson(),
};
}
@@ -3700,8 +3884,11 @@ class RequestSubscriptionIosProps {
this.advancedCommerceData,
this.andDangerouslyFinishTransactionAutomatically,
this.appAccountToken,
+ this.introductoryOfferEligibility,
+ this.promotionalOfferJWS,
this.quantity,
required this.sku,
+ this.winBackOffer,
this.withOffer,
});
@@ -3712,8 +3899,22 @@ class RequestSubscriptionIosProps {
final String? advancedCommerceData;
final bool? andDangerouslyFinishTransactionAutomatically;
final String? appAccountToken;
+ /// Override introductory offer eligibility (iOS 15+, WWDC 2025).
+ /// Set to true to indicate the user is eligible for introductory offer,
+ /// or false to indicate they are not. When nil, the system determines eligibility.
+ /// Back-deployed to iOS 15.
+ final bool? introductoryOfferEligibility;
+ /// JWS promotional offer (iOS 15+, WWDC 2025).
+ /// New signature format using compact JWS string for promotional offers.
+ /// Back-deployed to iOS 15.
+ final PromotionalOfferJWSInputIOS? promotionalOfferJWS;
final int? quantity;
final String sku;
+ /// Win-back offer to apply (iOS 18+)
+ /// Used to re-engage churned subscribers with a discount or free trial.
+ /// The offer is available when the customer is eligible and can be discovered
+ /// via StoreKit Message (automatic) or subscription offer APIs.
+ final WinBackOfferInputIOS? winBackOffer;
final DiscountOfferInputIOS? withOffer;
factory RequestSubscriptionIosProps.fromJson(Map json) {
@@ -3721,8 +3922,11 @@ class RequestSubscriptionIosProps {
advancedCommerceData: json['advancedCommerceData'] as String?,
andDangerouslyFinishTransactionAutomatically: json['andDangerouslyFinishTransactionAutomatically'] as bool?,
appAccountToken: json['appAccountToken'] as String?,
+ introductoryOfferEligibility: json['introductoryOfferEligibility'] as bool?,
+ promotionalOfferJWS: json['promotionalOfferJWS'] != null ? PromotionalOfferJWSInputIOS.fromJson(json['promotionalOfferJWS'] as Map) : null,
quantity: json['quantity'] as int?,
sku: json['sku'] as String,
+ winBackOffer: json['winBackOffer'] != null ? WinBackOfferInputIOS.fromJson(json['winBackOffer'] as Map) : null,
withOffer: json['withOffer'] != null ? DiscountOfferInputIOS.fromJson(json['withOffer'] as Map) : null,
);
}
@@ -3732,8 +3936,11 @@ class RequestSubscriptionIosProps {
'advancedCommerceData': advancedCommerceData,
'andDangerouslyFinishTransactionAutomatically': andDangerouslyFinishTransactionAutomatically,
'appAccountToken': appAccountToken,
+ 'introductoryOfferEligibility': introductoryOfferEligibility,
+ 'promotionalOfferJWS': promotionalOfferJWS?.toJson(),
'quantity': quantity,
'sku': sku,
+ 'winBackOffer': winBackOffer?.toJson(),
'withOffer': withOffer?.toJson(),
};
}
@@ -4054,6 +4261,31 @@ class VerifyPurchaseWithProviderProps {
}
}
+/// Win-back offer input for iOS 18+ (StoreKit 2)
+/// Win-back offers are used to re-engage churned subscribers.
+/// The offer is automatically presented via StoreKit Message when eligible,
+/// or can be applied programmatically during purchase.
+class WinBackOfferInputIOS {
+ const WinBackOfferInputIOS({
+ required this.offerId,
+ });
+
+ /// The win-back offer ID from App Store Connect
+ final String offerId;
+
+ factory WinBackOfferInputIOS.fromJson(Map json) {
+ return WinBackOfferInputIOS(
+ offerId: json['offerId'] as String,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'offerId': offerId,
+ };
+ }
+}
+
// MARK: - Unions
sealed class Product implements ProductCommon {
@@ -4367,6 +4599,7 @@ abstract class QueryResolver {
/// Get all available purchases for the current user
Future> getAvailablePurchases({
bool? alsoPublishToEventListenerIOS,
+ bool? includeSuspendedAndroid,
bool? onlyIncludeActiveItemsIOS,
});
/// Retrieve all pending transactions in the StoreKit queue
@@ -4541,6 +4774,7 @@ typedef QueryGetActiveSubscriptionsHandler = Future> Fu
typedef QueryGetAppTransactionIOSHandler = Future Function();
typedef QueryGetAvailablePurchasesHandler = Future> Function({
bool? alsoPublishToEventListenerIOS,
+ bool? includeSuspendedAndroid,
bool? onlyIncludeActiveItemsIOS,
});
typedef QueryGetPendingTransactionsIOSHandler = Future> Function();
diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd
index 830bf179..eb3932ed 100644
--- a/packages/gql/src/generated/types.gd
+++ b/packages/gql/src/generated/types.gd
@@ -191,6 +191,18 @@ enum ProductQueryType {
ALL = 2,
}
+## Status code for individual products returned from queryProductDetailsAsync (Android) Prior to 8.0, products that couldn't be fetched were simply not returned. With 8.0+, these products are returned with a status code explaining why. Available in Google Play Billing Library 8.0.0+
+enum ProductStatusAndroid {
+ ## Product was successfully fetched
+ OK = 0,
+ ## Product not found - the SKU doesn't exist in the Play Console
+ NOT_FOUND = 1,
+ ## No offers available for the user - product exists but user is not eligible for any offers
+ NO_OFFERS_AVAILABLE = 2,
+ ## Unknown error occurred while fetching the product
+ UNKNOWN = 3,
+}
+
enum ProductType {
IN_APP = 0,
SUBS = 1,
@@ -213,9 +225,21 @@ enum PurchaseVerificationProvider {
IAPKIT = 0,
}
+## Sub-response codes for more granular purchase error information (Android) Available in Google Play Billing Library 8.0.0+
+enum SubResponseCodeAndroid {
+ ## No specific sub-response code applies
+ NO_APPLICABLE_SUB_RESPONSE_CODE = 0,
+ ## User's payment method has insufficient funds
+ PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS = 1,
+ ## User doesn't meet subscription offer eligibility requirements
+ USER_INELIGIBLE = 2,
+}
+
enum SubscriptionOfferTypeIOS {
INTRODUCTORY = 0,
PROMOTIONAL = 1,
+ ## Win-back offer type (iOS 18+) Used to re-engage churned subscribers with a discount or free trial.
+ WIN_BACK = 2,
}
enum SubscriptionPeriodIOS {
@@ -443,6 +467,35 @@ class BillingProgramReportingDetailsAndroid:
dict["externalTransactionToken"] = external_transaction_token
return dict
+## Extended billing result with sub-response code (Android) Available in Google Play Billing Library 8.0.0+
+class BillingResultAndroid:
+ ## The response code from the billing operation
+ var response_code: int
+ ## Debug message from the billing library
+ var debug_message: String
+ ## Sub-response code for more granular error information (8.0+).
+ var sub_response_code: SubResponseCodeAndroid
+
+ static func from_dict(data: Dictionary) -> BillingResultAndroid:
+ var obj = BillingResultAndroid.new()
+ if data.has("responseCode") and data["responseCode"] != null:
+ obj.response_code = data["responseCode"]
+ if data.has("debugMessage") and data["debugMessage"] != null:
+ obj.debug_message = data["debugMessage"]
+ if data.has("subResponseCode") and data["subResponseCode"] != null:
+ obj.sub_response_code = data["subResponseCode"]
+ return obj
+
+ func to_dict() -> Dictionary:
+ var dict = {}
+ dict["responseCode"] = response_code
+ dict["debugMessage"] = debug_message
+ if SUB_RESPONSE_CODE_ANDROID_VALUES.has(sub_response_code):
+ dict["subResponseCode"] = SUB_RESPONSE_CODE_ANDROID_VALUES[sub_response_code]
+ else:
+ dict["subResponseCode"] = sub_response_code
+ return dict
+
## Details provided when user selects developer billing option (Android) Received via DeveloperProvidedBillingListener callback Available in Google Play Billing Library 8.3.0+
class DeveloperProvidedBillingDetailsAndroid:
## External transaction token used to report transactions made through developer billing.
@@ -918,6 +971,8 @@ class ProductAndroid:
var debug_description: String
var platform: IapPlatform
var name_android: String
+ ## Product-level status code indicating fetch result (Android 8.0+)
+ var product_status_android: ProductStatusAndroid
## Standardized discount offers for one-time products.
var discount_offers: Array[DiscountOffer]
## Standardized subscription offers.
@@ -951,6 +1006,8 @@ class ProductAndroid:
obj.platform = data["platform"]
if data.has("nameAndroid") and data["nameAndroid"] != null:
obj.name_android = data["nameAndroid"]
+ if data.has("productStatusAndroid") and data["productStatusAndroid"] != null:
+ obj.product_status_android = data["productStatusAndroid"]
if data.has("discountOffers") and data["discountOffers"] != null:
var arr = []
for item in data["discountOffers"]:
@@ -1004,6 +1061,10 @@ class ProductAndroid:
else:
dict["platform"] = platform
dict["nameAndroid"] = name_android
+ if PRODUCT_STATUS_ANDROID_VALUES.has(product_status_android):
+ dict["productStatusAndroid"] = PRODUCT_STATUS_ANDROID_VALUES[product_status_android]
+ else:
+ dict["productStatusAndroid"] = product_status_android
if discount_offers != null:
var arr = []
for item in discount_offers:
@@ -1262,6 +1323,8 @@ class ProductSubscriptionAndroid:
var debug_description: String
var platform: IapPlatform
var name_android: String
+ ## Product-level status code indicating fetch result (Android 8.0+)
+ var product_status_android: ProductStatusAndroid
## Standardized discount offers for one-time products.
var discount_offers: Array[DiscountOffer]
## Standardized subscription offers.
@@ -1295,6 +1358,8 @@ class ProductSubscriptionAndroid:
obj.platform = data["platform"]
if data.has("nameAndroid") and data["nameAndroid"] != null:
obj.name_android = data["nameAndroid"]
+ if data.has("productStatusAndroid") and data["productStatusAndroid"] != null:
+ obj.product_status_android = data["productStatusAndroid"]
if data.has("discountOffers") and data["discountOffers"] != null:
var arr = []
for item in data["discountOffers"]:
@@ -1348,6 +1413,10 @@ class ProductSubscriptionAndroid:
else:
dict["platform"] = platform
dict["nameAndroid"] = name_android
+ if PRODUCT_STATUS_ANDROID_VALUES.has(product_status_android):
+ dict["productStatusAndroid"] = PRODUCT_STATUS_ANDROID_VALUES[product_status_android]
+ else:
+ dict["productStatusAndroid"] = product_status_android
if discount_offers != null:
var arr = []
for item in discount_offers:
@@ -2808,6 +2877,29 @@ class ProductRequest:
dict["type"] = type
return dict
+## JWS promotional offer input for iOS 15+ (StoreKit 2, WWDC 2025). New signature format using compact JWS string for promotional offers. This provides a simpler alternative to the legacy signature-based promotional offers. Back-deployed to iOS 15.
+class PromotionalOfferJWSInputIOS:
+ ## The promotional offer identifier from App Store Connect
+ var offer_id: String
+ ## Compact JWS string signed by your server.
+ var jws: String
+
+ static func from_dict(data: Dictionary) -> PromotionalOfferJWSInputIOS:
+ var obj = PromotionalOfferJWSInputIOS.new()
+ if data.has("offerId") and data["offerId"] != null:
+ obj.offer_id = data["offerId"]
+ if data.has("jws") and data["jws"] != null:
+ obj.jws = data["jws"]
+ return obj
+
+ func to_dict() -> Dictionary:
+ var dict = {}
+ if offer_id != null:
+ dict["offerId"] = offer_id
+ if jws != null:
+ dict["jws"] = jws
+ return dict
+
class PurchaseInput:
var id: String
var product_id: String
@@ -2884,6 +2976,8 @@ class PurchaseOptions:
var also_publish_to_event_listener_ios: bool
## Limit to currently active items on iOS
var only_include_active_items_ios: bool
+ ## Include suspended subscriptions in the result (Android 8.1+).
+ var include_suspended_android: bool
static func from_dict(data: Dictionary) -> PurchaseOptions:
var obj = PurchaseOptions.new()
@@ -2891,6 +2985,8 @@ class PurchaseOptions:
obj.also_publish_to_event_listener_ios = data["alsoPublishToEventListenerIOS"]
if data.has("onlyIncludeActiveItemsIOS") and data["onlyIncludeActiveItemsIOS"] != null:
obj.only_include_active_items_ios = data["onlyIncludeActiveItemsIOS"]
+ if data.has("includeSuspendedAndroid") and data["includeSuspendedAndroid"] != null:
+ obj.include_suspended_android = data["includeSuspendedAndroid"]
return obj
func to_dict() -> Dictionary:
@@ -2899,6 +2995,8 @@ class PurchaseOptions:
dict["alsoPublishToEventListenerIOS"] = also_publish_to_event_listener_ios
if only_include_active_items_ios != null:
dict["onlyIncludeActiveItemsIOS"] = only_include_active_items_ios
+ if include_suspended_android != null:
+ dict["includeSuspendedAndroid"] = include_suspended_android
return dict
class RequestPurchaseAndroidProps:
@@ -2958,6 +3056,12 @@ class RequestPurchaseIosProps:
var quantity: int
## Discount offer to apply
var with_offer: DiscountOfferInputIOS
+ ## Win-back offer to apply (iOS 18+)
+ var win_back_offer: WinBackOfferInputIOS
+ ## JWS promotional offer (iOS 15+, WWDC 2025).
+ var promotional_offer_jws: PromotionalOfferJWSInputIOS
+ ## Override introductory offer eligibility (iOS 15+, WWDC 2025).
+ var introductory_offer_eligibility: bool
## Advanced commerce data token (iOS 15+).
var advanced_commerce_data: String
@@ -2976,6 +3080,18 @@ class RequestPurchaseIosProps:
obj.with_offer = DiscountOfferInputIOS.from_dict(data["withOffer"])
else:
obj.with_offer = data["withOffer"]
+ if data.has("winBackOffer") and data["winBackOffer"] != null:
+ if data["winBackOffer"] is Dictionary:
+ obj.win_back_offer = WinBackOfferInputIOS.from_dict(data["winBackOffer"])
+ else:
+ obj.win_back_offer = data["winBackOffer"]
+ if data.has("promotionalOfferJWS") and data["promotionalOfferJWS"] != null:
+ if data["promotionalOfferJWS"] is Dictionary:
+ obj.promotional_offer_jws = PromotionalOfferJWSInputIOS.from_dict(data["promotionalOfferJWS"])
+ else:
+ obj.promotional_offer_jws = data["promotionalOfferJWS"]
+ if data.has("introductoryOfferEligibility") and data["introductoryOfferEligibility"] != null:
+ obj.introductory_offer_eligibility = data["introductoryOfferEligibility"]
if data.has("advancedCommerceData") and data["advancedCommerceData"] != null:
obj.advanced_commerce_data = data["advancedCommerceData"]
return obj
@@ -2995,6 +3111,18 @@ class RequestPurchaseIosProps:
dict["withOffer"] = with_offer.to_dict()
else:
dict["withOffer"] = with_offer
+ if win_back_offer != null:
+ if win_back_offer.has_method("to_dict"):
+ dict["winBackOffer"] = win_back_offer.to_dict()
+ else:
+ dict["winBackOffer"] = win_back_offer
+ if promotional_offer_jws != null:
+ if promotional_offer_jws.has_method("to_dict"):
+ dict["promotionalOfferJWS"] = promotional_offer_jws.to_dict()
+ else:
+ dict["promotionalOfferJWS"] = promotional_offer_jws
+ if introductory_offer_eligibility != null:
+ dict["introductoryOfferEligibility"] = introductory_offer_eligibility
if advanced_commerce_data != null:
dict["advancedCommerceData"] = advanced_commerce_data
return dict
@@ -3201,6 +3329,12 @@ class RequestSubscriptionIosProps:
var app_account_token: String
var quantity: int
var with_offer: DiscountOfferInputIOS
+ ## Win-back offer to apply (iOS 18+)
+ var win_back_offer: WinBackOfferInputIOS
+ ## JWS promotional offer (iOS 15+, WWDC 2025).
+ var promotional_offer_jws: PromotionalOfferJWSInputIOS
+ ## Override introductory offer eligibility (iOS 15+, WWDC 2025).
+ var introductory_offer_eligibility: bool
## Advanced commerce data token (iOS 15+).
var advanced_commerce_data: String
@@ -3219,6 +3353,18 @@ class RequestSubscriptionIosProps:
obj.with_offer = DiscountOfferInputIOS.from_dict(data["withOffer"])
else:
obj.with_offer = data["withOffer"]
+ if data.has("winBackOffer") and data["winBackOffer"] != null:
+ if data["winBackOffer"] is Dictionary:
+ obj.win_back_offer = WinBackOfferInputIOS.from_dict(data["winBackOffer"])
+ else:
+ obj.win_back_offer = data["winBackOffer"]
+ if data.has("promotionalOfferJWS") and data["promotionalOfferJWS"] != null:
+ if data["promotionalOfferJWS"] is Dictionary:
+ obj.promotional_offer_jws = PromotionalOfferJWSInputIOS.from_dict(data["promotionalOfferJWS"])
+ else:
+ obj.promotional_offer_jws = data["promotionalOfferJWS"]
+ if data.has("introductoryOfferEligibility") and data["introductoryOfferEligibility"] != null:
+ obj.introductory_offer_eligibility = data["introductoryOfferEligibility"]
if data.has("advancedCommerceData") and data["advancedCommerceData"] != null:
obj.advanced_commerce_data = data["advancedCommerceData"]
return obj
@@ -3238,6 +3384,18 @@ class RequestSubscriptionIosProps:
dict["withOffer"] = with_offer.to_dict()
else:
dict["withOffer"] = with_offer
+ if win_back_offer != null:
+ if win_back_offer.has_method("to_dict"):
+ dict["winBackOffer"] = win_back_offer.to_dict()
+ else:
+ dict["winBackOffer"] = win_back_offer
+ if promotional_offer_jws != null:
+ if promotional_offer_jws.has_method("to_dict"):
+ dict["promotionalOfferJWS"] = promotional_offer_jws.to_dict()
+ else:
+ dict["promotionalOfferJWS"] = promotional_offer_jws
+ if introductory_offer_eligibility != null:
+ dict["introductoryOfferEligibility"] = introductory_offer_eligibility
if advanced_commerce_data != null:
dict["advancedCommerceData"] = advanced_commerce_data
return dict
@@ -3563,6 +3721,23 @@ class VerifyPurchaseWithProviderProps:
dict["iapkit"] = iapkit
return dict
+## Win-back offer input for iOS 18+ (StoreKit 2) Win-back offers are used to re-engage churned subscribers. The offer is automatically presented via StoreKit Message when eligible, or can be applied programmatically during purchase.
+class WinBackOfferInputIOS:
+ ## The win-back offer ID from App Store Connect
+ var offer_id: String
+
+ static func from_dict(data: Dictionary) -> WinBackOfferInputIOS:
+ var obj = WinBackOfferInputIOS.new()
+ if data.has("offerId") and data["offerId"] != null:
+ obj.offer_id = data["offerId"]
+ return obj
+
+ func to_dict() -> Dictionary:
+ var dict = {}
+ if offer_id != null:
+ dict["offerId"] = offer_id
+ return dict
+
# ============================================================================
# Enum String Helpers
# ============================================================================
@@ -3702,6 +3877,13 @@ const PRODUCT_QUERY_TYPE_VALUES = {
ProductQueryType.ALL: "all"
}
+const PRODUCT_STATUS_ANDROID_VALUES = {
+ ProductStatusAndroid.OK: "ok",
+ ProductStatusAndroid.NOT_FOUND: "not-found",
+ ProductStatusAndroid.NO_OFFERS_AVAILABLE: "no-offers-available",
+ ProductStatusAndroid.UNKNOWN: "unknown"
+}
+
const PRODUCT_TYPE_VALUES = {
ProductType.IN_APP: "in-app",
ProductType.SUBS: "subs"
@@ -3724,9 +3906,16 @@ const PURCHASE_VERIFICATION_PROVIDER_VALUES = {
PurchaseVerificationProvider.IAPKIT: "iapkit"
}
+const SUB_RESPONSE_CODE_ANDROID_VALUES = {
+ SubResponseCodeAndroid.NO_APPLICABLE_SUB_RESPONSE_CODE: "no-applicable-sub-response-code",
+ SubResponseCodeAndroid.PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS: "payment-declined-due-to-insufficient-funds",
+ SubResponseCodeAndroid.USER_INELIGIBLE: "user-ineligible"
+}
+
const SUBSCRIPTION_OFFER_TYPE_IOS_VALUES = {
SubscriptionOfferTypeIOS.INTRODUCTORY: "introductory",
- SubscriptionOfferTypeIOS.PROMOTIONAL: "promotional"
+ SubscriptionOfferTypeIOS.PROMOTIONAL: "promotional",
+ SubscriptionOfferTypeIOS.WIN_BACK: "win-back"
}
const SUBSCRIPTION_PERIOD_IOS_VALUES = {
diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts
index 3f6634b4..28e59152 100644
--- a/packages/gql/src/generated/types.ts
+++ b/packages/gql/src/generated/types.ts
@@ -99,6 +99,22 @@ export interface BillingProgramReportingDetailsAndroid {
externalTransactionToken: string;
}
+/**
+ * Extended billing result with sub-response code (Android)
+ * Available in Google Play Billing Library 8.0.0+
+ */
+export interface BillingResultAndroid {
+ /** Debug message from the billing library */
+ debugMessage?: (string | null);
+ /** The response code from the billing operation */
+ responseCode: number;
+ /**
+ * Sub-response code for more granular error information (8.0+).
+ * Provides additional context when responseCode indicates an error.
+ */
+ subResponseCode?: (SubResponseCodeAndroid | null);
+}
+
export interface DeepLinkOptions {
/** Android package name to target (required on Android) */
packageNameAndroid?: (string | null);
@@ -664,6 +680,14 @@ export interface ProductAndroid extends ProductCommon {
oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null);
platform: 'android';
price?: (number | null);
+ /**
+ * Product-level status code indicating fetch result (Android 8.0+)
+ * OK = product fetched successfully
+ * NOT_FOUND = SKU doesn't exist
+ * NO_OFFERS_AVAILABLE = user not eligible for any offers
+ * Available in Google Play Billing Library 8.0.0+
+ */
+ productStatusAndroid?: (ProductStatusAndroid | null);
/**
* @deprecated Use subscriptionOffers instead for cross-platform compatibility.
* @deprecated Use subscriptionOffers instead
@@ -769,6 +793,14 @@ export interface ProductRequest {
type?: (ProductQueryType | null);
}
+/**
+ * Status code for individual products returned from queryProductDetailsAsync (Android)
+ * Prior to 8.0, products that couldn't be fetched were simply not returned.
+ * With 8.0+, these products are returned with a status code explaining why.
+ * Available in Google Play Billing Library 8.0.0+
+ */
+export type ProductStatusAndroid = 'ok' | 'not-found' | 'no-offers-available' | 'unknown';
+
export type ProductSubscription = ProductSubscriptionAndroid | ProductSubscriptionIOS;
export interface ProductSubscriptionAndroid extends ProductCommon {
@@ -794,6 +826,14 @@ export interface ProductSubscriptionAndroid extends ProductCommon {
oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null);
platform: 'android';
price?: (number | null);
+ /**
+ * Product-level status code indicating fetch result (Android 8.0+)
+ * OK = product fetched successfully
+ * NOT_FOUND = SKU doesn't exist
+ * NO_OFFERS_AVAILABLE = user not eligible for any offers
+ * Available in Google Play Billing Library 8.0.0+
+ */
+ productStatusAndroid?: (ProductStatusAndroid | null);
/**
* @deprecated Use subscriptionOffers instead for cross-platform compatibility.
* @deprecated Use subscriptionOffers instead
@@ -866,6 +906,23 @@ export type ProductType = 'in-app' | 'subs';
export type ProductTypeIOS = 'consumable' | 'non-consumable' | 'auto-renewable-subscription' | 'non-renewing-subscription';
+/**
+ * JWS promotional offer input for iOS 15+ (StoreKit 2, WWDC 2025).
+ * New signature format using compact JWS string for promotional offers.
+ * This provides a simpler alternative to the legacy signature-based promotional offers.
+ * Back-deployed to iOS 15.
+ */
+export interface PromotionalOfferJwsInputIOS {
+ /**
+ * Compact JWS string signed by your server.
+ * The JWS should contain the promotional offer signature data.
+ * Format: header.payload.signature (base64url encoded)
+ */
+ jws: string;
+ /** The promotional offer identifier from App Store Connect */
+ offerId: string;
+}
+
export type Purchase = PurchaseAndroid | PurchaseIOS;
export interface PurchaseAndroid extends PurchaseCommon {
@@ -980,6 +1037,13 @@ export interface PurchaseOfferIOS {
export interface PurchaseOptions {
/** Also emit results through the iOS event listeners */
alsoPublishToEventListenerIOS?: (boolean | null);
+ /**
+ * Include suspended subscriptions in the result (Android 8.1+).
+ * Suspended subscriptions have isSuspendedAndroid=true and should NOT be granted entitlements.
+ * Users should be directed to the subscription center to resolve payment issues.
+ * Default: false (only active subscriptions are returned)
+ */
+ includeSuspendedAndroid?: (boolean | null);
/** Limit to currently active items on iOS */
onlyIncludeActiveItemsIOS?: (boolean | null);
}
@@ -1152,10 +1216,29 @@ export interface RequestPurchaseIosProps {
andDangerouslyFinishTransactionAutomatically?: (boolean | null);
/** App account token for user tracking */
appAccountToken?: (string | null);
+ /**
+ * Override introductory offer eligibility (iOS 15+, WWDC 2025).
+ * Set to true to indicate the user is eligible for introductory offer,
+ * or false to indicate they are not. When nil, the system determines eligibility.
+ * Back-deployed to iOS 15.
+ */
+ introductoryOfferEligibility?: (boolean | null);
+ /**
+ * JWS promotional offer (iOS 15+, WWDC 2025).
+ * New signature format using compact JWS string for promotional offers.
+ * Back-deployed to iOS 15.
+ */
+ promotionalOfferJWS?: (PromotionalOfferJwsInputIOS | null);
/** Purchase quantity */
quantity?: (number | null);
/** Product SKU */
sku: string;
+ /**
+ * Win-back offer to apply (iOS 18+)
+ * Used to re-engage churned subscribers with a discount or free trial.
+ * Note: Win-back offers only apply to subscription products.
+ */
+ winBackOffer?: (WinBackOfferInputIOS | null);
/** Discount offer to apply */
withOffer?: (DiscountOfferInputIOS | null);
}
@@ -1238,8 +1321,28 @@ export interface RequestSubscriptionIosProps {
advancedCommerceData?: (string | null);
andDangerouslyFinishTransactionAutomatically?: (boolean | null);
appAccountToken?: (string | null);
+ /**
+ * Override introductory offer eligibility (iOS 15+, WWDC 2025).
+ * Set to true to indicate the user is eligible for introductory offer,
+ * or false to indicate they are not. When nil, the system determines eligibility.
+ * Back-deployed to iOS 15.
+ */
+ introductoryOfferEligibility?: (boolean | null);
+ /**
+ * JWS promotional offer (iOS 15+, WWDC 2025).
+ * New signature format using compact JWS string for promotional offers.
+ * Back-deployed to iOS 15.
+ */
+ promotionalOfferJWS?: (PromotionalOfferJwsInputIOS | null);
quantity?: (number | null);
sku: string;
+ /**
+ * Win-back offer to apply (iOS 18+)
+ * Used to re-engage churned subscribers with a discount or free trial.
+ * The offer is available when the customer is eligible and can be discovered
+ * via StoreKit Message (automatic) or subscription offer APIs.
+ */
+ winBackOffer?: (WinBackOfferInputIOS | null);
withOffer?: (DiscountOfferInputIOS | null);
}
@@ -1295,6 +1398,12 @@ export interface RequestVerifyPurchaseWithIapkitResult {
store: IapStore;
}
+/**
+ * Sub-response codes for more granular purchase error information (Android)
+ * Available in Google Play Billing Library 8.0.0+
+ */
+export type SubResponseCodeAndroid = 'no-applicable-sub-response-code' | 'payment-declined-due-to-insufficient-funds' | 'user-ineligible';
+
export interface Subscription {
/**
* Fires when a user selects developer billing in the External Payments flow (Android only)
@@ -1415,7 +1524,7 @@ export interface SubscriptionOfferIOS {
type: SubscriptionOfferTypeIOS;
}
-export type SubscriptionOfferTypeIOS = 'introductory' | 'promotional';
+export type SubscriptionOfferTypeIOS = 'introductory' | 'promotional' | 'win-back';
/** Subscription period value combining unit and count. */
export interface SubscriptionPeriod {
@@ -1615,6 +1724,16 @@ export interface VerifyPurchaseWithProviderResult {
export type VoidResult = void;
+/**
+ * Win-back offer input for iOS 18+ (StoreKit 2)
+ * Win-back offers are used to re-engage churned subscribers.
+ * The offer is automatically presented via StoreKit Message when eligible,
+ * or can be applied programmatically during purchase.
+ */
+export interface WinBackOfferInputIOS {
+ /** The win-back offer ID from App Store Connect */
+ offerId: string;
+}
// -- Query helper types (auto-generated)
export type QueryArgsMap = {
canPresentExternalPurchaseNoticeIOS: never;
From 27ee1dedbfa6bf928471855b02a2e81516fa01b2 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sun, 18 Jan 2026 22:12:31 +0900
Subject: [PATCH 03/11] feat(apple): add win-back offers and JWS promotional
offer support
- Add winBackOffer support in purchase options (iOS 18+)
- Add promotionalOfferJWS for new JWS signature format (WWDC 2025)
- Add introductoryOfferEligibility override option (WWDC 2025)
- Update purchaseOptions to accept product for win-back offer lookup
- Regenerate Types.swift from GQL schema
Note: JWS and eligibility override APIs require Xcode 16.4+ to compile.
Implementation includes TODOs for when tooling is available.
Co-Authored-By: Claude Opus 4.5
---
.../Sources/Helpers/StoreKitTypesBridge.swift | 60 +++++++-
packages/apple/Sources/Models/Types.swift | 135 ++++++++++++++++++
packages/apple/Sources/OpenIapModule.swift | 5 +-
3 files changed, 198 insertions(+), 2 deletions(-)
diff --git a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift
index 4cd85257..e4109666 100644
--- a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift
+++ b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift
@@ -359,7 +359,7 @@ enum StoreKitTypesBridge {
}
}
- static func purchaseOptions(from props: RequestPurchaseIosProps) throws -> Set {
+ static func purchaseOptions(from props: RequestPurchaseIosProps, product: StoreKit.Product? = nil) throws -> Set {
var options: Set = []
if let quantity = props.quantity, quantity > 1 {
options.insert(.quantity(quantity))
@@ -377,6 +377,64 @@ enum StoreKitTypesBridge {
}
options.insert(option)
}
+ // Win-back offers (iOS 18+)
+ // Used to re-engage churned subscribers
+ if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) {
+ if let winBackInput = props.winBackOffer, let product = product {
+ // Find the win-back offer from the product's promotional offers
+ if let subscription = product.subscription {
+ let winBackOffer = subscription.promotionalOffers.first { offer in
+ offer.id == winBackInput.offerId && offer.type == .winBack
+ }
+ if let offer = winBackOffer {
+ options.insert(.winBackOffer(offer))
+ OpenIapLog.debug("✅ Added win-back offer: \(winBackInput.offerId)")
+ } else {
+ OpenIapLog.error("❌ Win-back offer not found: \(winBackInput.offerId)")
+ throw PurchaseError.make(
+ code: .developerError,
+ productId: props.sku,
+ message: "Win-back offer not found: \(winBackInput.offerId). Ensure the user is eligible and the offer ID is correct."
+ )
+ }
+ } else {
+ OpenIapLog.error("❌ Win-back offer requires a subscription product")
+ throw PurchaseError.make(
+ code: .developerError,
+ productId: props.sku,
+ message: "Win-back offers can only be applied to subscription products"
+ )
+ }
+ }
+ }
+ // JWS Promotional Offer (iOS 15+, WWDC 2025)
+ // New signature format using compact JWS string for promotional offers
+ // Back-deployed to iOS 15
+ if let jwsOffer = props.promotionalOfferJWS {
+ // Note: This uses the new promotionalOffer(_:) purchase option that accepts JWS
+ // The API was announced at WWDC 2025 and back-deployed to iOS 15
+ // We use the legacy promotional offer API as fallback since the new API
+ // requires Xcode 16.4+ / Swift 6.1+ to compile
+ OpenIapLog.debug("⚠️ JWS promotional offer provided: \(jwsOffer.offerId)")
+ // TODO: When Xcode 16.4+ is available, use:
+ // options.insert(.promotionalOffer(jwsOffer.jws))
+ // For now, log a warning - developers should use withOffer for promotional offers
+ OpenIapLog.debug("⚠️ JWS promotional offers require Xcode 16.4+. Use withOffer with signature-based promotional offers instead.")
+ }
+
+ // Introductory Offer Eligibility Override (iOS 15+, WWDC 2025)
+ // Allows overriding the system's eligibility check for introductory offers
+ // Back-deployed to iOS 15
+ if let eligibility = props.introductoryOfferEligibility {
+ // Note: This uses the new introductoryOfferEligibility(_:) purchase option
+ // The API was announced at WWDC 2025 and back-deployed to iOS 15
+ // We need Xcode 16.4+ / Swift 6.1+ to compile this
+ OpenIapLog.debug("⚠️ Introductory offer eligibility override requested: \(eligibility)")
+ // TODO: When Xcode 16.4+ is available, use:
+ // options.insert(.introductoryOfferEligibility(eligibility))
+ OpenIapLog.debug("⚠️ Introductory offer eligibility override requires Xcode 16.4+. The system will determine eligibility automatically.")
+ }
+
// Advanced Commerce Data (iOS 15+)
// Used with StoreKit 2's Product.PurchaseOption.custom API for passing
// campaign tokens, affiliate IDs, or other attribution data
diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift
index 656e0b20..f176fbf0 100644
--- a/packages/apple/Sources/Models/Types.swift
+++ b/packages/apple/Sources/Models/Types.swift
@@ -299,6 +299,21 @@ public enum ProductQueryType: String, Codable, CaseIterable {
case all = "all"
}
+/// Status code for individual products returned from queryProductDetailsAsync (Android)
+/// Prior to 8.0, products that couldn't be fetched were simply not returned.
+/// With 8.0+, these products are returned with a status code explaining why.
+/// Available in Google Play Billing Library 8.0.0+
+public enum ProductStatusAndroid: String, Codable, CaseIterable {
+ /// Product was successfully fetched
+ case ok = "ok"
+ /// Product not found - the SKU doesn't exist in the Play Console
+ case notFound = "not-found"
+ /// No offers available for the user - product exists but user is not eligible for any offers
+ case noOffersAvailable = "no-offers-available"
+ /// Unknown error occurred while fetching the product
+ case unknown = "unknown"
+}
+
public enum ProductType: String, Codable, CaseIterable {
case inApp = "in-app"
case subs = "subs"
@@ -321,9 +336,23 @@ public enum PurchaseVerificationProvider: String, Codable, CaseIterable {
case iapkit = "iapkit"
}
+/// Sub-response codes for more granular purchase error information (Android)
+/// Available in Google Play Billing Library 8.0.0+
+public enum SubResponseCodeAndroid: String, Codable, CaseIterable {
+ /// No specific sub-response code applies
+ case noApplicableSubResponseCode = "no-applicable-sub-response-code"
+ /// User's payment method has insufficient funds
+ case paymentDeclinedDueToInsufficientFunds = "payment-declined-due-to-insufficient-funds"
+ /// User doesn't meet subscription offer eligibility requirements
+ case userIneligible = "user-ineligible"
+}
+
public enum SubscriptionOfferTypeIOS: String, Codable, CaseIterable {
case introductory = "introductory"
case promotional = "promotional"
+ /// Win-back offer type (iOS 18+)
+ /// Used to re-engage churned subscribers with a discount or free trial.
+ case winBack = "win-back"
}
public enum SubscriptionPeriodIOS: String, Codable, CaseIterable {
@@ -463,6 +492,18 @@ public struct BillingProgramReportingDetailsAndroid: Codable {
public var externalTransactionToken: String
}
+/// Extended billing result with sub-response code (Android)
+/// Available in Google Play Billing Library 8.0.0+
+public struct BillingResultAndroid: Codable {
+ /// Debug message from the billing library
+ public var debugMessage: String?
+ /// The response code from the billing operation
+ public var responseCode: Int
+ /// Sub-response code for more granular error information (8.0+).
+ /// Provides additional context when responseCode indicates an error.
+ public var subResponseCode: SubResponseCodeAndroid?
+}
+
/// Details provided when user selects developer billing option (Android)
/// Received via DeveloperProvidedBillingListener callback
/// Available in Google Play Billing Library 8.3.0+
@@ -668,6 +709,12 @@ public struct ProductAndroid: Codable, ProductCommon {
public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]?
public var platform: IapPlatform = .android
public var price: Double?
+ /// Product-level status code indicating fetch result (Android 8.0+)
+ /// OK = product fetched successfully
+ /// NOT_FOUND = SKU doesn't exist
+ /// NO_OFFERS_AVAILABLE = user not eligible for any offers
+ /// Available in Google Play Billing Library 8.0.0+
+ public var productStatusAndroid: ProductStatusAndroid?
/// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
public var subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails]?
/// Standardized subscription offers.
@@ -751,6 +798,12 @@ public struct ProductSubscriptionAndroid: Codable, ProductCommon {
public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]?
public var platform: IapPlatform = .android
public var price: Double?
+ /// Product-level status code indicating fetch result (Android 8.0+)
+ /// OK = product fetched successfully
+ /// NOT_FOUND = SKU doesn't exist
+ /// NO_OFFERS_AVAILABLE = user not eligible for any offers
+ /// Available in Google Play Billing Library 8.0.0+
+ public var productStatusAndroid: ProductStatusAndroid?
/// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
public var subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails]
/// Standardized subscription offers.
@@ -1280,19 +1333,47 @@ public struct ProductRequest: Codable {
}
}
+/// JWS promotional offer input for iOS 15+ (StoreKit 2, WWDC 2025).
+/// New signature format using compact JWS string for promotional offers.
+/// This provides a simpler alternative to the legacy signature-based promotional offers.
+/// Back-deployed to iOS 15.
+public struct PromotionalOfferJWSInputIOS: Codable {
+ /// Compact JWS string signed by your server.
+ /// The JWS should contain the promotional offer signature data.
+ /// Format: header.payload.signature (base64url encoded)
+ public var jws: String
+ /// The promotional offer identifier from App Store Connect
+ public var offerId: String
+
+ public init(
+ jws: String,
+ offerId: String
+ ) {
+ self.jws = jws
+ self.offerId = offerId
+ }
+}
+
public typealias PurchaseInput = Purchase
public struct PurchaseOptions: Codable {
/// Also emit results through the iOS event listeners
public var alsoPublishToEventListenerIOS: Bool?
+ /// Include suspended subscriptions in the result (Android 8.1+).
+ /// Suspended subscriptions have isSuspendedAndroid=true and should NOT be granted entitlements.
+ /// Users should be directed to the subscription center to resolve payment issues.
+ /// Default: false (only active subscriptions are returned)
+ public var includeSuspendedAndroid: Bool?
/// Limit to currently active items on iOS
public var onlyIncludeActiveItemsIOS: Bool?
public init(
alsoPublishToEventListenerIOS: Bool? = nil,
+ includeSuspendedAndroid: Bool? = nil,
onlyIncludeActiveItemsIOS: Bool? = nil
) {
self.alsoPublishToEventListenerIOS = alsoPublishToEventListenerIOS
+ self.includeSuspendedAndroid = includeSuspendedAndroid
self.onlyIncludeActiveItemsIOS = onlyIncludeActiveItemsIOS
}
}
@@ -1336,10 +1417,23 @@ public struct RequestPurchaseIosProps: Codable {
public var andDangerouslyFinishTransactionAutomatically: Bool?
/// App account token for user tracking
public var appAccountToken: String?
+ /// Override introductory offer eligibility (iOS 15+, WWDC 2025).
+ /// Set to true to indicate the user is eligible for introductory offer,
+ /// or false to indicate they are not. When nil, the system determines eligibility.
+ /// Back-deployed to iOS 15.
+ public var introductoryOfferEligibility: Bool?
+ /// JWS promotional offer (iOS 15+, WWDC 2025).
+ /// New signature format using compact JWS string for promotional offers.
+ /// Back-deployed to iOS 15.
+ public var promotionalOfferJWS: PromotionalOfferJWSInputIOS?
/// Purchase quantity
public var quantity: Int?
/// Product SKU
public var sku: String
+ /// Win-back offer to apply (iOS 18+)
+ /// Used to re-engage churned subscribers with a discount or free trial.
+ /// Note: Win-back offers only apply to subscription products.
+ public var winBackOffer: WinBackOfferInputIOS?
/// Discount offer to apply
public var withOffer: DiscountOfferInputIOS?
@@ -1347,15 +1441,21 @@ public struct RequestPurchaseIosProps: Codable {
advancedCommerceData: String? = nil,
andDangerouslyFinishTransactionAutomatically: Bool? = nil,
appAccountToken: String? = nil,
+ introductoryOfferEligibility: Bool? = nil,
+ promotionalOfferJWS: PromotionalOfferJWSInputIOS? = nil,
quantity: Int? = nil,
sku: String,
+ winBackOffer: WinBackOfferInputIOS? = nil,
withOffer: DiscountOfferInputIOS? = nil
) {
self.advancedCommerceData = advancedCommerceData
self.andDangerouslyFinishTransactionAutomatically = andDangerouslyFinishTransactionAutomatically
self.appAccountToken = appAccountToken
+ self.introductoryOfferEligibility = introductoryOfferEligibility
+ self.promotionalOfferJWS = promotionalOfferJWS
self.quantity = quantity
self.sku = sku
+ self.winBackOffer = winBackOffer
self.withOffer = withOffer
}
}
@@ -1514,23 +1614,43 @@ public struct RequestSubscriptionIosProps: Codable {
public var advancedCommerceData: String?
public var andDangerouslyFinishTransactionAutomatically: Bool?
public var appAccountToken: String?
+ /// Override introductory offer eligibility (iOS 15+, WWDC 2025).
+ /// Set to true to indicate the user is eligible for introductory offer,
+ /// or false to indicate they are not. When nil, the system determines eligibility.
+ /// Back-deployed to iOS 15.
+ public var introductoryOfferEligibility: Bool?
+ /// JWS promotional offer (iOS 15+, WWDC 2025).
+ /// New signature format using compact JWS string for promotional offers.
+ /// Back-deployed to iOS 15.
+ public var promotionalOfferJWS: PromotionalOfferJWSInputIOS?
public var quantity: Int?
public var sku: String
+ /// Win-back offer to apply (iOS 18+)
+ /// Used to re-engage churned subscribers with a discount or free trial.
+ /// The offer is available when the customer is eligible and can be discovered
+ /// via StoreKit Message (automatic) or subscription offer APIs.
+ public var winBackOffer: WinBackOfferInputIOS?
public var withOffer: DiscountOfferInputIOS?
public init(
advancedCommerceData: String? = nil,
andDangerouslyFinishTransactionAutomatically: Bool? = nil,
appAccountToken: String? = nil,
+ introductoryOfferEligibility: Bool? = nil,
+ promotionalOfferJWS: PromotionalOfferJWSInputIOS? = nil,
quantity: Int? = nil,
sku: String,
+ winBackOffer: WinBackOfferInputIOS? = nil,
withOffer: DiscountOfferInputIOS? = nil
) {
self.advancedCommerceData = advancedCommerceData
self.andDangerouslyFinishTransactionAutomatically = andDangerouslyFinishTransactionAutomatically
self.appAccountToken = appAccountToken
+ self.introductoryOfferEligibility = introductoryOfferEligibility
+ self.promotionalOfferJWS = promotionalOfferJWS
self.quantity = quantity
self.sku = sku
+ self.winBackOffer = winBackOffer
self.withOffer = withOffer
}
}
@@ -1735,6 +1855,21 @@ public struct VerifyPurchaseWithProviderProps: Codable {
}
}
+/// Win-back offer input for iOS 18+ (StoreKit 2)
+/// Win-back offers are used to re-engage churned subscribers.
+/// The offer is automatically presented via StoreKit Message when eligible,
+/// or can be applied programmatically during purchase.
+public struct WinBackOfferInputIOS: Codable {
+ /// The win-back offer ID from App Store Connect
+ public var offerId: String
+
+ public init(
+ offerId: String
+ ) {
+ self.offerId = offerId
+ }
+}
+
// MARK: - Unions
public enum Product: Codable, ProductCommon {
diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift
index d91fa5f8..2741bcd2 100644
--- a/packages/apple/Sources/OpenIapModule.swift
+++ b/packages/apple/Sources/OpenIapModule.swift
@@ -218,7 +218,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
let iosProps = try resolveIosPurchaseProps(from: params)
let sku = iosProps.sku
let product = try await storeProduct(for: sku)
- let options = try StoreKitTypesBridge.purchaseOptions(from: iosProps)
+ let options = try StoreKitTypesBridge.purchaseOptions(from: iosProps, product: product)
// Check if subscription is already owned before attempting purchase
// This prevents iOS from showing "You're already subscribed" alert
@@ -1275,8 +1275,11 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
advancedCommerceData: ios.advancedCommerceData,
andDangerouslyFinishTransactionAutomatically: ios.andDangerouslyFinishTransactionAutomatically,
appAccountToken: ios.appAccountToken,
+ introductoryOfferEligibility: ios.introductoryOfferEligibility,
+ promotionalOfferJWS: ios.promotionalOfferJWS,
quantity: ios.quantity,
sku: ios.sku,
+ winBackOffer: ios.winBackOffer,
withOffer: ios.withOffer
)
}
From 56edaf289a18ebd98464fccbde6ff76612645229 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sun, 18 Jan 2026 22:12:45 +0900
Subject: [PATCH 04/11] feat(google): add product status support for Billing
Library 8.0+
- Add ProductStatusAndroid field to product types
- Implement getProductStatus() using reflection for 8.0+ compatibility
- Maps status codes: OK, NOT_FOUND, NO_OFFERS_AVAILABLE
- Gracefully returns null for older billing library versions
- Regenerate Types.kt from GQL schema
This enables better error handling when products fail to fetch,
as 8.0+ now returns status codes instead of silently omitting products.
Co-Authored-By: Claude Opus 4.5
---
.../src/main/java/dev/hyo/openiap/Types.kt | 259 +++++++++++++++++-
.../java/dev/hyo/openiap/OpenIapModule.kt | 7 +-
.../java/dev/hyo/openiap/helpers/Helpers.kt | 30 +-
.../hyo/openiap/utils/BillingConverters.kt | 21 ++
4 files changed, 309 insertions(+), 8 deletions(-)
diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt
index cf3db7b4..b319f92f 100644
--- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt
+++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt
@@ -608,6 +608,47 @@ public enum class ProductQueryType(val rawValue: String) {
fun toJson(): String = rawValue
}
+/**
+ * Status code for individual products returned from queryProductDetailsAsync (Android)
+ * Prior to 8.0, products that couldn't be fetched were simply not returned.
+ * With 8.0+, these products are returned with a status code explaining why.
+ * Available in Google Play Billing Library 8.0.0+
+ */
+public enum class ProductStatusAndroid(val rawValue: String) {
+ /**
+ * Product was successfully fetched
+ */
+ Ok("ok"),
+ /**
+ * Product not found - the SKU doesn't exist in the Play Console
+ */
+ NotFound("not-found"),
+ /**
+ * No offers available for the user - product exists but user is not eligible for any offers
+ */
+ NoOffersAvailable("no-offers-available"),
+ /**
+ * Unknown error occurred while fetching the product
+ */
+ Unknown("unknown");
+
+ companion object {
+ fun fromJson(value: String): ProductStatusAndroid = when (value) {
+ "ok" -> ProductStatusAndroid.Ok
+ "Ok" -> ProductStatusAndroid.Ok
+ "not-found" -> ProductStatusAndroid.NotFound
+ "NotFound" -> ProductStatusAndroid.NotFound
+ "no-offers-available" -> ProductStatusAndroid.NoOffersAvailable
+ "NoOffersAvailable" -> ProductStatusAndroid.NoOffersAvailable
+ "unknown" -> ProductStatusAndroid.Unknown
+ "Unknown" -> ProductStatusAndroid.Unknown
+ else -> throw IllegalArgumentException("Unknown ProductStatusAndroid value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
public enum class ProductType(val rawValue: String) {
InApp("in-app"),
Subs("subs");
@@ -682,9 +723,47 @@ public enum class PurchaseVerificationProvider(val rawValue: String) {
fun toJson(): String = rawValue
}
+/**
+ * Sub-response codes for more granular purchase error information (Android)
+ * Available in Google Play Billing Library 8.0.0+
+ */
+public enum class SubResponseCodeAndroid(val rawValue: String) {
+ /**
+ * No specific sub-response code applies
+ */
+ NoApplicableSubResponseCode("no-applicable-sub-response-code"),
+ /**
+ * User's payment method has insufficient funds
+ */
+ PaymentDeclinedDueToInsufficientFunds("payment-declined-due-to-insufficient-funds"),
+ /**
+ * User doesn't meet subscription offer eligibility requirements
+ */
+ UserIneligible("user-ineligible");
+
+ companion object {
+ fun fromJson(value: String): SubResponseCodeAndroid = when (value) {
+ "no-applicable-sub-response-code" -> SubResponseCodeAndroid.NoApplicableSubResponseCode
+ "NoApplicableSubResponseCode" -> SubResponseCodeAndroid.NoApplicableSubResponseCode
+ "payment-declined-due-to-insufficient-funds" -> SubResponseCodeAndroid.PaymentDeclinedDueToInsufficientFunds
+ "PaymentDeclinedDueToInsufficientFunds" -> SubResponseCodeAndroid.PaymentDeclinedDueToInsufficientFunds
+ "user-ineligible" -> SubResponseCodeAndroid.UserIneligible
+ "UserIneligible" -> SubResponseCodeAndroid.UserIneligible
+ else -> throw IllegalArgumentException("Unknown SubResponseCodeAndroid value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
public enum class SubscriptionOfferTypeIOS(val rawValue: String) {
Introductory("introductory"),
- Promotional("promotional");
+ Promotional("promotional"),
+ /**
+ * Win-back offer type (iOS 18+)
+ * Used to re-engage churned subscribers with a discount or free trial.
+ */
+ WinBack("win-back");
companion object {
fun fromJson(value: String): SubscriptionOfferTypeIOS = when (value) {
@@ -692,6 +771,8 @@ public enum class SubscriptionOfferTypeIOS(val rawValue: String) {
"Introductory" -> SubscriptionOfferTypeIOS.Introductory
"promotional" -> SubscriptionOfferTypeIOS.Promotional
"Promotional" -> SubscriptionOfferTypeIOS.Promotional
+ "win-back" -> SubscriptionOfferTypeIOS.WinBack
+ "WinBack" -> SubscriptionOfferTypeIOS.WinBack
else -> throw IllegalArgumentException("Unknown SubscriptionOfferTypeIOS value: $value")
}
}
@@ -1048,6 +1129,44 @@ public data class BillingProgramReportingDetailsAndroid(
)
}
+/**
+ * Extended billing result with sub-response code (Android)
+ * Available in Google Play Billing Library 8.0.0+
+ */
+public data class BillingResultAndroid(
+ /**
+ * Debug message from the billing library
+ */
+ val debugMessage: String? = null,
+ /**
+ * The response code from the billing operation
+ */
+ val responseCode: Int,
+ /**
+ * Sub-response code for more granular error information (8.0+).
+ * Provides additional context when responseCode indicates an error.
+ */
+ val subResponseCode: SubResponseCodeAndroid? = null
+) {
+
+ companion object {
+ fun fromJson(json: Map): BillingResultAndroid {
+ return BillingResultAndroid(
+ debugMessage = json["debugMessage"] as? String,
+ responseCode = (json["responseCode"] as? Number)?.toInt() ?: 0,
+ subResponseCode = (json["subResponseCode"] as? String)?.let { SubResponseCodeAndroid.fromJson(it) },
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "__typename" to "BillingResultAndroid",
+ "debugMessage" to debugMessage,
+ "responseCode" to responseCode,
+ "subResponseCode" to subResponseCode?.toJson(),
+ )
+}
+
/**
* Details provided when user selects developer billing option (Android)
* Received via DeveloperProvidedBillingListener callback
@@ -1639,6 +1758,14 @@ public data class ProductAndroid(
val oneTimePurchaseOfferDetailsAndroid: List? = null,
override val platform: IapPlatform = IapPlatform.Android,
override val price: Double? = null,
+ /**
+ * Product-level status code indicating fetch result (Android 8.0+)
+ * OK = product fetched successfully
+ * NOT_FOUND = SKU doesn't exist
+ * NO_OFFERS_AVAILABLE = user not eligible for any offers
+ * Available in Google Play Billing Library 8.0.0+
+ */
+ val productStatusAndroid: ProductStatusAndroid? = null,
/**
* @deprecated Use subscriptionOffers instead for cross-platform compatibility.
*/
@@ -1667,6 +1794,7 @@ public data class ProductAndroid(
oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductAndroidOneTimePurchaseOfferDetail.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductAndroidOneTimePurchaseOfferDetail") },
platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
price = (json["price"] as? Number)?.toDouble(),
+ productStatusAndroid = (json["productStatusAndroid"] as? String)?.let { ProductStatusAndroid.fromJson(it) },
subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductSubscriptionAndroidOfferDetails.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductSubscriptionAndroidOfferDetails") },
subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") },
title = json["title"] as? String ?: "",
@@ -1688,6 +1816,7 @@ public data class ProductAndroid(
"oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseOfferDetailsAndroid?.map { it.toJson() },
"platform" to platform.toJson(),
"price" to price,
+ "productStatusAndroid" to productStatusAndroid?.toJson(),
"subscriptionOfferDetailsAndroid" to subscriptionOfferDetailsAndroid?.map { it.toJson() },
"subscriptionOffers" to subscriptionOffers?.map { it.toJson() },
"title" to title,
@@ -1876,6 +2005,14 @@ public data class ProductSubscriptionAndroid(
val oneTimePurchaseOfferDetailsAndroid: List? = null,
override val platform: IapPlatform = IapPlatform.Android,
override val price: Double? = null,
+ /**
+ * Product-level status code indicating fetch result (Android 8.0+)
+ * OK = product fetched successfully
+ * NOT_FOUND = SKU doesn't exist
+ * NO_OFFERS_AVAILABLE = user not eligible for any offers
+ * Available in Google Play Billing Library 8.0.0+
+ */
+ val productStatusAndroid: ProductStatusAndroid? = null,
/**
* @deprecated Use subscriptionOffers instead for cross-platform compatibility.
*/
@@ -1904,6 +2041,7 @@ public data class ProductSubscriptionAndroid(
oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductAndroidOneTimePurchaseOfferDetail.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductAndroidOneTimePurchaseOfferDetail") },
platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
price = (json["price"] as? Number)?.toDouble(),
+ productStatusAndroid = (json["productStatusAndroid"] as? String)?.let { ProductStatusAndroid.fromJson(it) },
subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductSubscriptionAndroidOfferDetails.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductSubscriptionAndroidOfferDetails") } ?: emptyList(),
subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") } ?: emptyList(),
title = json["title"] as? String ?: "",
@@ -1925,6 +2063,7 @@ public data class ProductSubscriptionAndroid(
"oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseOfferDetailsAndroid?.map { it.toJson() },
"platform" to platform.toJson(),
"price" to price,
+ "productStatusAndroid" to productStatusAndroid?.toJson(),
"subscriptionOfferDetailsAndroid" to subscriptionOfferDetailsAndroid.map { it.toJson() },
"subscriptionOffers" to subscriptionOffers.map { it.toJson() },
"title" to title,
@@ -3258,6 +3397,39 @@ public data class ProductRequest(
)
}
+/**
+ * JWS promotional offer input for iOS 15+ (StoreKit 2, WWDC 2025).
+ * New signature format using compact JWS string for promotional offers.
+ * This provides a simpler alternative to the legacy signature-based promotional offers.
+ * Back-deployed to iOS 15.
+ */
+public data class PromotionalOfferJWSInputIOS(
+ /**
+ * Compact JWS string signed by your server.
+ * The JWS should contain the promotional offer signature data.
+ * Format: header.payload.signature (base64url encoded)
+ */
+ val jws: String,
+ /**
+ * The promotional offer identifier from App Store Connect
+ */
+ val offerId: String
+) {
+ companion object {
+ fun fromJson(json: Map): PromotionalOfferJWSInputIOS {
+ return PromotionalOfferJWSInputIOS(
+ jws = json["jws"] as? String ?: "",
+ offerId = json["offerId"] as? String ?: "",
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "jws" to jws,
+ "offerId" to offerId,
+ )
+}
+
public typealias PurchaseInput = Purchase
public data class PurchaseOptions(
@@ -3265,6 +3437,13 @@ public data class PurchaseOptions(
* Also emit results through the iOS event listeners
*/
val alsoPublishToEventListenerIOS: Boolean? = null,
+ /**
+ * Include suspended subscriptions in the result (Android 8.1+).
+ * Suspended subscriptions have isSuspendedAndroid=true and should NOT be granted entitlements.
+ * Users should be directed to the subscription center to resolve payment issues.
+ * Default: false (only active subscriptions are returned)
+ */
+ val includeSuspendedAndroid: Boolean? = null,
/**
* Limit to currently active items on iOS
*/
@@ -3274,6 +3453,7 @@ public data class PurchaseOptions(
fun fromJson(json: Map): PurchaseOptions {
return PurchaseOptions(
alsoPublishToEventListenerIOS = json["alsoPublishToEventListenerIOS"] as? Boolean,
+ includeSuspendedAndroid = json["includeSuspendedAndroid"] as? Boolean,
onlyIncludeActiveItemsIOS = json["onlyIncludeActiveItemsIOS"] as? Boolean,
)
}
@@ -3281,6 +3461,7 @@ public data class PurchaseOptions(
fun toJson(): Map = mapOf(
"alsoPublishToEventListenerIOS" to alsoPublishToEventListenerIOS,
+ "includeSuspendedAndroid" to includeSuspendedAndroid,
"onlyIncludeActiveItemsIOS" to onlyIncludeActiveItemsIOS,
)
}
@@ -3346,6 +3527,19 @@ public data class RequestPurchaseIosProps(
* App account token for user tracking
*/
val appAccountToken: String? = null,
+ /**
+ * Override introductory offer eligibility (iOS 15+, WWDC 2025).
+ * Set to true to indicate the user is eligible for introductory offer,
+ * or false to indicate they are not. When nil, the system determines eligibility.
+ * Back-deployed to iOS 15.
+ */
+ val introductoryOfferEligibility: Boolean? = null,
+ /**
+ * JWS promotional offer (iOS 15+, WWDC 2025).
+ * New signature format using compact JWS string for promotional offers.
+ * Back-deployed to iOS 15.
+ */
+ val promotionalOfferJWS: PromotionalOfferJWSInputIOS? = null,
/**
* Purchase quantity
*/
@@ -3354,6 +3548,12 @@ public data class RequestPurchaseIosProps(
* Product SKU
*/
val sku: String,
+ /**
+ * Win-back offer to apply (iOS 18+)
+ * Used to re-engage churned subscribers with a discount or free trial.
+ * Note: Win-back offers only apply to subscription products.
+ */
+ val winBackOffer: WinBackOfferInputIOS? = null,
/**
* Discount offer to apply
*/
@@ -3365,8 +3565,11 @@ public data class RequestPurchaseIosProps(
advancedCommerceData = json["advancedCommerceData"] as? String,
andDangerouslyFinishTransactionAutomatically = json["andDangerouslyFinishTransactionAutomatically"] as? Boolean,
appAccountToken = json["appAccountToken"] as? String,
+ introductoryOfferEligibility = json["introductoryOfferEligibility"] as? Boolean,
+ promotionalOfferJWS = (json["promotionalOfferJWS"] as? Map)?.let { PromotionalOfferJWSInputIOS.fromJson(it) },
quantity = (json["quantity"] as? Number)?.toInt(),
sku = json["sku"] as? String ?: "",
+ winBackOffer = (json["winBackOffer"] as? Map)?.let { WinBackOfferInputIOS.fromJson(it) },
withOffer = (json["withOffer"] as? Map)?.let { DiscountOfferInputIOS.fromJson(it) },
)
}
@@ -3376,8 +3579,11 @@ public data class RequestPurchaseIosProps(
"advancedCommerceData" to advancedCommerceData,
"andDangerouslyFinishTransactionAutomatically" to andDangerouslyFinishTransactionAutomatically,
"appAccountToken" to appAccountToken,
+ "introductoryOfferEligibility" to introductoryOfferEligibility,
+ "promotionalOfferJWS" to promotionalOfferJWS?.toJson(),
"quantity" to quantity,
"sku" to sku,
+ "winBackOffer" to winBackOffer?.toJson(),
"withOffer" to withOffer?.toJson(),
)
}
@@ -3561,8 +3767,28 @@ public data class RequestSubscriptionIosProps(
val advancedCommerceData: String? = null,
val andDangerouslyFinishTransactionAutomatically: Boolean? = null,
val appAccountToken: String? = null,
+ /**
+ * Override introductory offer eligibility (iOS 15+, WWDC 2025).
+ * Set to true to indicate the user is eligible for introductory offer,
+ * or false to indicate they are not. When nil, the system determines eligibility.
+ * Back-deployed to iOS 15.
+ */
+ val introductoryOfferEligibility: Boolean? = null,
+ /**
+ * JWS promotional offer (iOS 15+, WWDC 2025).
+ * New signature format using compact JWS string for promotional offers.
+ * Back-deployed to iOS 15.
+ */
+ val promotionalOfferJWS: PromotionalOfferJWSInputIOS? = null,
val quantity: Int? = null,
val sku: String,
+ /**
+ * Win-back offer to apply (iOS 18+)
+ * Used to re-engage churned subscribers with a discount or free trial.
+ * The offer is available when the customer is eligible and can be discovered
+ * via StoreKit Message (automatic) or subscription offer APIs.
+ */
+ val winBackOffer: WinBackOfferInputIOS? = null,
val withOffer: DiscountOfferInputIOS? = null
) {
companion object {
@@ -3571,8 +3797,11 @@ public data class RequestSubscriptionIosProps(
advancedCommerceData = json["advancedCommerceData"] as? String,
andDangerouslyFinishTransactionAutomatically = json["andDangerouslyFinishTransactionAutomatically"] as? Boolean,
appAccountToken = json["appAccountToken"] as? String,
+ introductoryOfferEligibility = json["introductoryOfferEligibility"] as? Boolean,
+ promotionalOfferJWS = (json["promotionalOfferJWS"] as? Map)?.let { PromotionalOfferJWSInputIOS.fromJson(it) },
quantity = (json["quantity"] as? Number)?.toInt(),
sku = json["sku"] as? String ?: "",
+ winBackOffer = (json["winBackOffer"] as? Map)?.let { WinBackOfferInputIOS.fromJson(it) },
withOffer = (json["withOffer"] as? Map)?.let { DiscountOfferInputIOS.fromJson(it) },
)
}
@@ -3582,8 +3811,11 @@ public data class RequestSubscriptionIosProps(
"advancedCommerceData" to advancedCommerceData,
"andDangerouslyFinishTransactionAutomatically" to andDangerouslyFinishTransactionAutomatically,
"appAccountToken" to appAccountToken,
+ "introductoryOfferEligibility" to introductoryOfferEligibility,
+ "promotionalOfferJWS" to promotionalOfferJWS?.toJson(),
"quantity" to quantity,
"sku" to sku,
+ "winBackOffer" to winBackOffer?.toJson(),
"withOffer" to withOffer?.toJson(),
)
}
@@ -3908,6 +4140,31 @@ public data class VerifyPurchaseWithProviderProps(
)
}
+/**
+ * Win-back offer input for iOS 18+ (StoreKit 2)
+ * Win-back offers are used to re-engage churned subscribers.
+ * The offer is automatically presented via StoreKit Message when eligible,
+ * or can be applied programmatically during purchase.
+ */
+public data class WinBackOfferInputIOS(
+ /**
+ * The win-back offer ID from App Store Connect
+ */
+ val offerId: String
+) {
+ companion object {
+ fun fromJson(json: Map): WinBackOfferInputIOS {
+ return WinBackOfferInputIOS(
+ offerId = json["offerId"] as? String ?: "",
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "offerId" to offerId,
+ )
+}
+
// MARK: - Unions
public sealed interface Product : ProductCommon {
diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
index a59d6b0c..bb33a16c 100644
--- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
+++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
@@ -226,8 +226,11 @@ class OpenIapModule(
}
}
}
- override val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { _ ->
- withContext(Dispatchers.IO) { restorePurchasesHelper(billingClient) }
+ override val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { options ->
+ withContext(Dispatchers.IO) {
+ val includeSuspended = options?.includeSuspendedAndroid == true
+ restorePurchasesHelper(billingClient, includeSuspended)
+ }
}
override val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler = { subscriptionIds ->
diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt
index 457371fe..87f4e406 100644
--- a/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt
+++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt
@@ -46,23 +46,43 @@ internal suspend fun onPurchaseError(
continuation.invokeOnCancellation { removeListener(listener) }
}
-internal suspend fun restorePurchases(client: BillingClient?): List {
+internal suspend fun restorePurchases(
+ client: BillingClient?,
+ includeSuspended: Boolean = false
+): List {
if (client == null) return emptyList()
val purchases = mutableListOf()
- purchases += queryPurchases(client, BillingClient.ProductType.INAPP)
- purchases += queryPurchases(client, BillingClient.ProductType.SUBS)
+ purchases += queryPurchases(client, BillingClient.ProductType.INAPP, includeSuspended = false)
+ purchases += queryPurchases(client, BillingClient.ProductType.SUBS, includeSuspended)
return purchases
}
internal suspend fun queryPurchases(
client: BillingClient?,
- productType: String
+ productType: String,
+ includeSuspended: Boolean = false
): List = suspendCancellableCoroutine { continuation ->
val billingClient = client ?: run {
continuation.resume(emptyList())
return@suspendCancellableCoroutine
}
- val params = QueryPurchasesParams.newBuilder().setProductType(productType).build()
+ val paramsBuilder = QueryPurchasesParams.newBuilder().setProductType(productType)
+
+ // Include suspended subscriptions (Google Play Billing Library 8.1+)
+ // Suspended subscriptions have isSuspendedAndroid=true and should NOT be granted entitlements.
+ // Users should be directed to the subscription center to resolve payment issues.
+ if (productType == BillingClient.ProductType.SUBS && includeSuspended) {
+ runCatching {
+ // Use reflection to maintain backward compatibility with older billing library versions
+ val setIncludeSuspendedMethod = paramsBuilder::class.java.getMethod(
+ "setIncludeSuspended",
+ Boolean::class.javaPrimitiveType
+ )
+ setIncludeSuspendedMethod.invoke(paramsBuilder, true)
+ }
+ }
+
+ val params = paramsBuilder.build()
billingClient.queryPurchasesAsync(params) { result, purchaseList ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
val mapped = purchaseList.map { billingPurchase ->
diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt
index c3569b08..25b153b3 100644
--- a/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt
+++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt
@@ -15,6 +15,7 @@ import dev.hyo.openiap.Product
import dev.hyo.openiap.ProductAndroid
import dev.hyo.openiap.PreorderDetailsAndroid
import dev.hyo.openiap.ProductAndroidOneTimePurchaseOfferDetail
+import dev.hyo.openiap.ProductStatusAndroid
import dev.hyo.openiap.ProductSubscriptionAndroid
import dev.hyo.openiap.ProductSubscriptionAndroidOfferDetails
import dev.hyo.openiap.ProductType
@@ -32,6 +33,24 @@ import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase as BillingPurchase
internal object BillingConverters {
+ /**
+ * Gets the product status from ProductDetails (Billing Library 8.0+).
+ * Returns null for older billing library versions.
+ */
+ private fun ProductDetails.getProductStatus(): ProductStatusAndroid? {
+ return runCatching {
+ // ProductDetails.productStatus is available in Billing Library 8.0+
+ val statusMethod = this::class.java.getMethod("getProductStatus")
+ val status = statusMethod.invoke(this) as? Int
+ when (status) {
+ 0 -> ProductStatusAndroid.Ok // ProductDetails.ProductStatus.OK
+ 1 -> ProductStatusAndroid.NotFound // ProductDetails.ProductStatus.NOT_FOUND
+ 2 -> ProductStatusAndroid.NoOffersAvailable // ProductDetails.ProductStatus.NO_OFFERS_AVAILABLE
+ else -> ProductStatusAndroid.Unknown
+ }
+ }.getOrNull()
+ }
+
/**
* Converts a ProductDetails.OneTimePurchaseOfferDetails to ProductAndroidOneTimePurchaseOfferDetail
* This includes all discount-related fields available in Billing Library 7.0+
@@ -266,6 +285,7 @@ internal object BillingConverters {
oneTimePurchaseOfferDetailsAndroid = offerDetailsList,
platform = IapPlatform.Android,
price = priceAmountMicros.toDouble() / 1_000_000.0,
+ productStatusAndroid = getProductStatus(),
subscriptionOfferDetailsAndroid = null,
subscriptionOffers = null,
title = title,
@@ -331,6 +351,7 @@ internal object BillingConverters {
oneTimePurchaseOfferDetailsAndroid = oneTimeOfferDetailsList,
platform = IapPlatform.Android,
price = firstPhase?.priceAmountMicros?.toDouble()?.div(1_000_000.0),
+ productStatusAndroid = getProductStatus(),
subscriptionOfferDetailsAndroid = pricingDetails,
subscriptionOffers = subscriptionOffers,
title = title,
From 47b72d6681f0d5d693031a26baaf90afbe4a1cb9 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sun, 18 Jan 2026 22:13:00 +0900
Subject: [PATCH 05/11] docs: add release notes and type documentation for new
APIs
- Add release notes for gql 1.3.14, apple 1.3.12, google 1.3.24
- Document ProductStatusAndroid enum in product types page
- Document WinBackOfferInputIOS in offer types page
- Update llms.txt with new API information for AI assistants
Co-Authored-By: Claude Opus 4.5
---
packages/docs/public/llms-full.txt | 329 +++++++++++++++++-
packages/docs/public/llms.txt | 2 +-
packages/docs/src/pages/docs/types/offer.tsx | 9 +-
.../docs/src/pages/docs/types/product.tsx | 19 +
.../docs/src/pages/docs/updates/notes.tsx | 83 +++++
5 files changed, 422 insertions(+), 20 deletions(-)
diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt
index bda0fb02..4b8d40cf 100644
--- a/packages/docs/public/llms-full.txt
+++ b/packages/docs/public/llms-full.txt
@@ -3,7 +3,7 @@
> OpenIAP: Unified in-app purchase specification for iOS & Android
> Documentation: https://openiap.dev
> Quick Reference: https://openiap.dev/llms.txt
-> Generated: 2026-01-18T10:51:44.321Z
+> Generated: 2026-01-18T13:00:35.102Z
## Table of Contents
1. Installation
@@ -430,13 +430,25 @@ await endConnection();
# Google Play Billing Library API Reference
-> Reference documentation for Google Play Billing Library 7.x
+> Reference documentation for Google Play Billing Library 8.x
> Adapt all patterns to match OpenIAP internal conventions.
## Overview
Google Play Billing Library enables in-app purchases and subscriptions on Android devices.
+## Version History
+
+| Version | Release Date | Key Features |
+|---------|--------------|--------------|
+| 8.0 | 2025-06-30 | Auto-reconnect, product-level status codes, one-time products with multiple offers, sub-response codes |
+| 8.1 | 2025-11-06 | Suspended subscriptions (`isSuspended`), `includeSuspended` parameter, pre-order details, product-level subscription replacement, `KEEP_EXISTING` mode |
+| 8.2 | 2025-12-09 | Billing Programs API (external content links, external offers), deprecates old External Offers API |
+| 8.2.1 | 2025-12-15 | Bug fix for `isBillingProgramAvailableAsync()` and `createBillingProgramReportingDetailsAsync()` |
+| 8.3 | 2025-12-23 | External Payments program (Japan only), developer billing options |
+
+**Current Version**: 8.3.0 (as of January 2026)
+
## Core Classes
### BillingClient
@@ -447,9 +459,24 @@ The main interface for communicating with Google Play Billing.
val billingClient = BillingClient.newBuilder(context)
.setListener(purchasesUpdatedListener)
.enablePendingPurchases()
+ // New in 8.0: Auto-reconnect on service disconnect
+ .enableAutoServiceReconnection()
+ .build()
+```
+
+### Auto Service Reconnection (8.0+)
+
+```kotlin
+// Enables automatic reconnection when service disconnects
+BillingClient.newBuilder(context)
+ .enableAutoServiceReconnection()
.build()
```
+When enabled, the library automatically re-establishes the connection if an API call is made while disconnected. This reduces `SERVICE_DISCONNECTED` errors.
+
+> **OpenIAP Note**: Auto-reconnection is **always enabled** internally since OpenIAP uses Billing Library 8.3.0+. No configuration needed.
+
### Connection Management
```kotlin
@@ -667,13 +694,126 @@ if (result.responseCode == BillingClient.BillingResponseCode.OK) {
- `PRICE_CHANGE_CONFIRMATION` - Price change confirmation
- `PRODUCT_DETAILS` - Product details API
+## Product-Level Status Codes (8.0+)
+
+In Billing Library 8.0+, `queryProductDetailsAsync()` returns products that couldn't be fetched with a status code explaining why.
+
+```kotlin
+billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
+ productDetailsList.forEach { productDetails ->
+ when (productDetails.productStatus) {
+ ProductDetails.ProductStatus.OK -> {
+ // Product fetched successfully
+ }
+ ProductDetails.ProductStatus.NOT_FOUND -> {
+ // SKU doesn't exist in Play Console
+ }
+ ProductDetails.ProductStatus.NO_OFFERS_AVAILABLE -> {
+ // User not eligible for any offers
+ }
+ }
+ }
+}
+```
+
+| Status | Description |
+|--------|-------------|
+| `OK` | Product fetched successfully |
+| `NOT_FOUND` | SKU doesn't exist in Play Console |
+| `NO_OFFERS_AVAILABLE` | User not eligible for any offers |
+
+## Suspended Subscriptions (8.1+)
+
+```kotlin
+val purchase: Purchase
+
+// Check if subscription is suspended due to billing issue
+if (purchase.isSuspended) {
+ // User's payment method failed
+ // Do NOT grant entitlements
+ // Direct user to subscription center to fix payment
+}
+```
+
+### Query Suspended Subscriptions (8.1+)
+
+```kotlin
+// Include suspended subscriptions in query results
+val params = QueryPurchasesParams.newBuilder()
+ .setProductType(BillingClient.ProductType.SUBS)
+ .setIncludeSuspended(true) // New in 8.1
+ .build()
+
+billingClient.queryPurchasesAsync(params) { billingResult, purchases ->
+ purchases.forEach { purchase ->
+ if (purchase.isSuspended) {
+ // Handle suspended subscription
+ }
+ }
+}
+```
+
+> **OpenIAP Note**: Use `includeSuspendedAndroid: true` in `PurchaseOptions` when calling `getAvailablePurchases()`. The `isSuspendedAndroid` field on purchases indicates suspension status.
+
+## Sub-Response Codes (8.0+)
+
+`BillingResult` includes a sub-response code for more granular error information:
+
+```kotlin
+val result = billingClient.launchBillingFlow(activity, params)
+when (result.subResponseCode) {
+ BillingResult.SUB_RESPONSE_CODE_INSUFFICIENT_FUNDS -> {
+ // User's payment method has insufficient funds
+ }
+ BillingResult.SUB_RESPONSE_CODE_USER_INELIGIBLE -> {
+ // User doesn't meet offer eligibility requirements
+ }
+}
+```
+
+| Sub-Response Code | Description |
+|-------------------|-------------|
+| `PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS` | User's payment method has insufficient funds |
+| `USER_INELIGIBLE` | User doesn't meet subscription offer eligibility |
+| `NO_APPLICABLE_SUB_RESPONSE_CODE` | No specific sub-code applies |
+
+## Subscription Product Replacement (8.1+)
+
+Product-level replacement parameters for subscription upgrades/downgrades:
+
+```kotlin
+val replacementParams = SubscriptionProductReplacementParams.newBuilder()
+ .setOldProductId("old_subscription_id")
+ .setReplacementMode(ReplacementMode.WITH_TIME_PRORATION)
+ .build()
+
+val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder()
+ .setProductDetails(newProductDetails)
+ .setOfferToken(offerToken)
+ .setSubscriptionProductReplacementParams(replacementParams) // New in 8.1
+ .build()
+```
+
+### Replacement Modes
+
+| Mode | Description |
+|------|-------------|
+| `WITH_TIME_PRORATION` | Immediate, expiration time prorated |
+| `CHARGE_PRORATED_PRICE` | Immediate, same billing cycle |
+| `CHARGE_FULL_PRICE` | Immediate, full price charged |
+| `WITHOUT_PRORATION` | Takes effect on old plan expiration |
+| `DEFERRED` | Deferred, no charge |
+| `KEEP_EXISTING` | Keep existing payment schedule (8.1+) |
+
## Best Practices
1. **Always acknowledge purchases** within 3 days or they will be refunded
2. **Verify purchases server-side** using Google Play Developer API
3. **Handle pending purchases** for payment methods that require additional steps
-4. **Reconnect on disconnect** - billing service can disconnect anytime
-5. **Cache product details** to avoid repeated queries
+4. **Auto-reconnect is enabled by default** in OpenIAP (8.0+)
+5. **Check product status codes** (8.0+) to understand why products weren't fetched
+6. **Check isSuspended** (8.1+) before granting entitlements
+7. **Cache product details** to avoid repeated queries
---
@@ -681,7 +821,7 @@ if (result.responseCode == BillingClient.BillingResponseCode.OK) {
# Meta Horizon IAP API Reference
> External reference for Meta Horizon Store in-app purchase APIs.
-> Source: https://developers.meta.com/horizon/documentation/
+> Source: [Meta Horizon Documentation](https://developers.meta.com/horizon/documentation/)
## Overview
@@ -694,12 +834,17 @@ Meta Horizon provides IAP functionality for Quest VR applications. There are two
| Library | Version | Compatible With |
|---------|---------|-----------------|
-| horizon-billing-compatibility | 1.1.1 | Google Play Billing **7.0** API |
-| Google Play Billing (Play flavor) | 8.3.0 | N/A |
-| react-native-iap | v14+ | Billing 7.0+ |
-| expo-iap | latest | Billing 7.0+ |
+| horizon-billing-compatibility | **1.1.1** (latest) | Google Play Billing **7.0** API |
+| Google Play Billing (Play flavor) | **8.3.0** (latest) | N/A |
+| react-native-iap | v14+ | Billing 7.0+, RN 0.79+, Kotlin 2.0+ |
+| expo-iap | latest | Billing 7.0+, Kotlin 2.0+ |
+
+**CRITICAL**: Horizon Billing Compatibility SDK implements Google Play Billing **7.0** API surface, NOT 8.x.
-**IMPORTANT**: Horizon Billing Compatibility SDK implements Google Play Billing **7.0** API surface, NOT 8.x. When writing shared code for both Play and Horizon flavors, use only APIs that exist in both Billing 7.0 and 8.x.
+When writing shared code for both Play and Horizon flavors:
+- Use only APIs that exist in **both** Billing 7.0 and 8.x
+- Horizon SDK does NOT support Billing 8.x features like auto-reconnect, product status codes, or `includeSuspended`
+- OpenIAP handles this automatically with flavor-specific implementations
### APIs Available in Both (Safe to use in shared code)
@@ -712,9 +857,15 @@ Meta Horizon provides IAP functionality for Quest VR applications. There are two
### APIs Only in Billing 8.x (DO NOT use in shared code)
-- `enableAutoServiceReconnection()` - Auto reconnect feature
-- Product-level status codes in `queryProductDetailsAsync()` response
-- One-time products with multiple offers
+- `enableAutoServiceReconnection()` - Auto reconnect feature (8.0+)
+- Product-level status codes in `queryProductDetailsAsync()` response (8.0+)
+- One-time products with multiple offers (8.0+)
+- Sub-response codes in `BillingResult` (8.0+)
+- `isSuspended` on Purchase (8.1+)
+- `includeSuspended` parameter in `QueryPurchasesParams` (8.1+)
+- `SubscriptionProductReplacementParams` (8.1+)
+- Billing Programs API (`isBillingProgramAvailableAsync`, etc.) (8.2+)
+- External Payments / Developer Billing Options (8.3+)
## Billing Compatibility SDK
@@ -764,7 +915,8 @@ Access token format: `OC|App_ID|App_Secret`
Verify that a user owns an item (app or add-on).
**Endpoint:**
-```
+
+```http
POST https://graph.oculus.com/$APP_ID/verify_entitlement
```
@@ -772,7 +924,7 @@ POST https://graph.oculus.com/$APP_ID/verify_entitlement
| Parameter | Type | Description |
|-----------|------|-------------|
-| `access_token` | string | `OC|App_ID|App_Secret` format |
+| `access_token` | string | `OC\|App_ID\|App_Secret` format |
| `user_id` | string | The user ID to verify |
| `sku` | string | (Optional) SKU for add-on verification |
@@ -803,7 +955,8 @@ curl -d "access_token=OC|$APP_ID|$APP_SECRET" \
Refund a DURABLE or CONSUMABLE entitlement (not yet consumed).
**Endpoint:**
-```
+
+```http
POST https://graph.oculus.com/$APP_ID/refund_iap_entitlement
```
@@ -811,7 +964,7 @@ POST https://graph.oculus.com/$APP_ID/refund_iap_entitlement
| Parameter | Type | Description |
|-----------|------|-------------|
-| `access_token` | string | `OC|App_ID|App_Secret` format |
+| `access_token` | string | `OC\|App_ID\|App_Secret` format |
| `user_id` | string | The user ID |
| `sku` | string | SKU of item to refund |
@@ -1349,6 +1502,30 @@ export default withIAPContext(Store);
This document provides external API reference for Apple's StoreKit 2 framework.
+## iOS 18+ Features
+
+| Feature | iOS Version | Description |
+|---------|-------------|-------------|
+| Win-back offers | iOS 18.0 | Re-engage churned subscribers |
+| Consumable transaction history | iOS 18.0 | History includes finished consumables |
+| Billing issue messages | iOS 18.0 | Automatic billing issue notifications via StoreKit Message |
+| UI context for purchases | iOS 18.2 | Required for proper payment sheet display |
+| External purchase notice | iOS 18.2 | `presentExternalPurchaseNoticeSheetIOS` |
+| `appTransactionID` | iOS 18.4 | Globally unique app transaction identifier (back-deployed to iOS 15) |
+| `originalPlatform` | iOS 18.4 | Original purchase platform (back-deployed to iOS 15) |
+| `Offer.Period` | iOS 18.4 | Offer period information |
+| `advancedCommerceInfo` | iOS 18.4 | Advanced Commerce API data |
+| Expanded offer codes | iOS 18.4 | For consumables/non-consumables |
+| JWS promotional offers | WWDC 2025 | New `promotionalOffer` purchase option with JWS format |
+| `introductoryOfferEligibility` | WWDC 2025 | Set eligibility via purchase option |
+
+### WWDC 2025 Updates
+
+- **SubscriptionStatus by Transaction ID**: Query subscription status using any transaction ID
+- **JWS-based promotional offers**: New `promotionalOffer` purchase option with compact JWS string
+- **Introductory offer eligibility**: Override eligibility check with `introductoryOfferEligibility` purchase option
+- Both new purchase options are back-deployed to iOS 15
+
## Product
A type that describes an in-app purchase product.
@@ -1456,6 +1633,124 @@ static func beginRefundRequest(for transactionID: UInt64, in scene: UIWindowScen
Begins a refund request for a transaction.
+## Win-Back Offers (iOS 18+)
+
+Win-back offers are a new offer type to re-engage churned subscribers.
+
+### Automatic Presentation
+
+StoreKit Message automatically presents win-back offers when a user is eligible:
+
+```swift
+// Message reason for win-back offers
+StoreKit.Message.Reason.winBackOffer
+```
+
+### Manual Application
+
+Apply a win-back offer during purchase:
+
+```swift
+let product: Product
+let winBackOffer: Product.SubscriptionOffer
+
+let result = try await product.purchase(options: [
+ .winBackOffer(winBackOffer)
+])
+```
+
+### Checking Eligibility
+
+```swift
+// Win-back offers are available in subscription.promotionalOffers
+// with type == .winBack
+let winBackOffers = product.subscription?.promotionalOffers.filter {
+ $0.type == .winBack
+}
+```
+
+### RenewalInfo
+
+Win-back offer information is available in renewal info:
+
+```swift
+let renewalInfo: Product.SubscriptionInfo.RenewalInfo
+
+// Check if win-back offer is applied to next renewal
+if renewalInfo.renewalOfferType == .winBack {
+ // Win-back offer will be applied
+}
+```
+
+## UI Context for Purchases (iOS 18.2+)
+
+Beginning in iOS 18.2, purchase methods require a UI context to properly display payment sheets:
+
+```swift
+// iOS/iPadOS/tvOS/visionOS: UIViewController
+let result = try await product.purchase(confirmIn: viewController)
+
+// macOS: NSWindow
+let result = try await product.purchase(confirmIn: window)
+
+// watchOS: No UI context required
+```
+
+> **OpenIAP Note**: UI context is handled automatically in OpenIAP using the active window scene.
+
+## AppTransaction Updates (iOS 18.4+)
+
+```swift
+let appTransaction = try await AppTransaction.shared
+
+// New in iOS 18.4 (back-deployed to iOS 15)
+let appTransactionID = appTransaction.appTransactionID // Globally unique per Apple Account
+let originalPlatform = appTransaction.originalPlatform // Original purchase platform
+```
+
+### appTransactionID
+
+- Globally unique identifier for each Apple Account that downloads your app
+- Remains consistent across redownloads, refunds, repurchases, and storefront changes
+- Works with Family Sharing (each family member gets unique ID)
+- Back-deployed to iOS 15
+
+## Advanced Commerce API (iOS 18.4+)
+
+For apps with large product catalogs:
+
+```swift
+// Check if product has advanced commerce info
+if let advancedInfo = product.advancedCommerceInfo {
+ // Handle large catalog monetization
+}
+```
+
+## External Purchase Support (iOS 18.2+)
+
+### Present External Purchase Notice
+
+```swift
+// Check if external purchase notice can be presented
+if await ExternalPurchase.canPresent {
+ let result = try await ExternalPurchase.presentNoticeSheet()
+ switch result {
+ case .continue:
+ // User wants to continue to external purchase
+ case .dismissed:
+ // User dismissed the notice
+ }
+}
+```
+
+### Present External Purchase Link
+
+```swift
+let result = try await ExternalPurchase.open(url: externalURL)
+```
+
+> **OpenIAP Note**: `presentExternalPurchaseNoticeSheetIOS` and `presentExternalPurchaseLinkIOS` are available in the iOS package.
+
---
diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt
index 8c4ce8da..3e545b9a 100644
--- a/packages/docs/public/llms.txt
+++ b/packages/docs/public/llms.txt
@@ -3,7 +3,7 @@
> OpenIAP: Unified in-app purchase specification for iOS & Android
> Documentation: https://openiap.dev
> Full Reference: https://openiap.dev/llms-full.txt
-> Generated: 2026-01-18T10:51:44.321Z
+> Generated: 2026-01-18T13:00:35.102Z
## Installation
diff --git a/packages/docs/src/pages/docs/types/offer.tsx b/packages/docs/src/pages/docs/types/offer.tsx
index 8063bb65..0871b6fb 100644
--- a/packages/docs/src/pages/docs/types/offer.tsx
+++ b/packages/docs/src/pages/docs/types/offer.tsx
@@ -125,7 +125,7 @@ function TypesOffer() {
DiscountOfferType!
|
- Type of offer (Introductory, Promotional, OneTime) |
+ Type of offer: Introductory, Promotional, WinBack (iOS 18+), or OneTime |
@@ -268,6 +268,7 @@ function TypesOffer() {
enum DiscountOfferType {
Introductory = 'Introductory',
Promotional = 'Promotional',
+ WinBack = 'WinBack', // iOS 18+
OneTime = 'OneTime',
}`}
),
@@ -296,6 +297,7 @@ enum DiscountOfferType {
enum DiscountOfferType: String, Codable {
case introductory = "Introductory"
case promotional = "Promotional"
+ case winBack = "WinBack" // iOS 18+
case oneTime = "OneTime"
}`}
),
@@ -324,6 +326,7 @@ enum DiscountOfferType: String, Codable {
enum class DiscountOfferType {
Introductory,
Promotional,
+ WinBack, // iOS 18+
OneTime
}`}
),
@@ -370,6 +373,7 @@ enum class DiscountOfferType {
enum DiscountOfferType {
introductory,
promotional,
+ winBack, // iOS 18+
oneTime,
}`}
),
@@ -398,6 +402,7 @@ var rental_details_android: RentalDetailsAndroid
enum DiscountOfferType {
INTRODUCTORY,
PROMOTIONAL,
+ WIN_BACK, # iOS 18+
ONE_TIME
}`}
),
@@ -470,7 +475,7 @@ enum DiscountOfferType {
DiscountOfferType!
|
- Introductory or Promotional |
+ Introductory, Promotional, or WinBack (iOS 18+) |
diff --git a/packages/docs/src/pages/docs/types/product.tsx b/packages/docs/src/pages/docs/types/product.tsx
index 3e808696..5bba51a9 100644
--- a/packages/docs/src/pages/docs/types/product.tsx
+++ b/packages/docs/src/pages/docs/types/product.tsx
@@ -250,6 +250,25 @@ function TypesProduct() {
offerToken, pricingPhases
|
+
+
+ productStatusAndroid
+ |
+
+ Product fetch status code. Values: OK (success),{' '}
+ NOT_FOUND (SKU doesn't exist),{' '}
+ NO_OFFERS_AVAILABLE (user not eligible for any offers),{' '}
+ UNKNOWN.
+ Requires{' '}
+
+ Billing Library 8.0+
+
+ |
+
>
diff --git a/packages/docs/src/pages/docs/updates/notes.tsx b/packages/docs/src/pages/docs/updates/notes.tsx
index 48f29599..79c16b80 100644
--- a/packages/docs/src/pages/docs/updates/notes.tsx
+++ b/packages/docs/src/pages/docs/updates/notes.tsx
@@ -26,6 +26,89 @@ function Notes() {
useScrollToHash();
const allNotes: Note[] = [
+ // GQL 1.3.13 / Google 1.3.24 / Apple 1.3.11 - Jan 18, 2026
+ {
+ id: 'gql-1-3-13-google-1-3-24-apple-1-3-11',
+ date: new Date('2026-01-18'),
+ element: (
+
+
+ 📅 openiap-gql v1.3.13 / openiap-google v1.3.24 / openiap-apple v1.3.11 - Platform API Gap Analysis
+
+
+
iOS - Win-Back Offers (iOS 18+):
+
+ Added support for win-back offers to re-engage churned subscribers.
+
+
+ winBackOffer - New field in RequestPurchaseIosProps and RequestSubscriptionIosProps
+ WinBackOfferInputIOS - Input type with offerId field
+ SubscriptionOfferTypeIOS.WinBack - New enum value
+
+
+{`// Apply win-back offer to subscription purchase
+requestSubscription({
+ sku: 'premium_monthly',
+ winBackOffer: { offerId: 'winback_50_off' }
+});`}
+
+
+
iOS - JWS Promotional Offers (iOS 15+, WWDC 2025):
+
+ New signature format using compact JWS string for promotional offers. Back-deployed to iOS 15.
+
+
+ promotionalOfferJWS - New field in purchase props
+ PromotionalOfferJWSInputIOS - Input type with offerId and jws fields
+
+
+ Note: Requires Xcode 16.4+ to compile. Falls back to legacy signature-based offers until then.
+
+
+
iOS - Introductory Offer Eligibility Override (iOS 15+, WWDC 2025):
+
+ introductoryOfferEligibility - Override system eligibility check for intro offers
+ - Set
true to indicate eligible, false for not eligible, nil for system default
+
+
+ Note: Requires Xcode 16.4+ to compile. System determines eligibility automatically until then.
+
+
+
+
+
Android - Product Status Codes (Billing 8.0+):
+
+ Product-level status codes indicating why products couldn't be fetched.
+
+
+ ProductStatusAndroid - New enum with values: OK, NOT_FOUND, NO_OFFERS_AVAILABLE, UNKNOWN
+ productStatusAndroid - New field on ProductAndroid and ProductSubscriptionAndroid
+
+
+{`// Check product fetch status
+val product = fetchProducts(skus).firstOrNull()
+when (product?.productStatusAndroid) {
+ ProductStatusAndroid.Ok -> { /* Success */ }
+ ProductStatusAndroid.NotFound -> { /* SKU doesn't exist */ }
+ ProductStatusAndroid.NoOffersAvailable -> { /* User not eligible */ }
+}`}
+
+
+
Android - Auto Service Reconnection:
+
+ enableAutoServiceReconnection() is now always enabled internally since OpenIAP uses Billing Library 8.3.0+.
+ No configuration needed - the library automatically re-establishes connection if disconnected.
+
+
+
References:
+
+
+ ),
+ },
// GQL 1.3.12 / Google 1.3.22 / Apple 1.3.10 - Jan 17, 2026
{
id: 'gql-1-3-12-google-1-3-22-apple-1-3-10',
From 9219fff11268857413e90f5cec07d43bb744366c Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sun, 18 Jan 2026 22:13:14 +0900
Subject: [PATCH 06/11] chore(knowledge): update external API references and
context
- Add Google Play Billing Library 8.0 API details (ProductStatus, SubResponseCode)
- Add StoreKit 2 WWDC 2025 APIs (win-back offers, JWS promotional offers)
- Update Horizon API documentation
- Regenerate Claude context file with latest API information
Co-Authored-By: Claude Opus 4.5
---
knowledge/_claude-context/context.md | 334 +++++++++++++++++++++--
knowledge/external/google-billing-api.md | 146 +++++++++-
knowledge/external/horizon-api.md | 27 +-
knowledge/external/storekit2-api.md | 142 ++++++++++
4 files changed, 619 insertions(+), 30 deletions(-)
diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md
index d4dc309c..4b9eeb96 100644
--- a/knowledge/_claude-context/context.md
+++ b/knowledge/_claude-context/context.md
@@ -1,7 +1,7 @@
# OpenIAP Project Context
> **Auto-generated for Claude Code**
-> Last updated: 2026-01-18T10:51:44.310Z
+> Last updated: 2026-01-18T13:00:35.017Z
>
> Usage: `claude --context knowledge/_claude-context/context.md`
@@ -659,7 +659,7 @@ async function fetchProducts(productIds: string[]): Promise {
## Apple Package (packages/apple)
-### Required Pre-Work
+### Required Pre-Work (Apple)
Before writing or editing anything, **ALWAYS** review:
- [`packages/apple/CONVENTION.md`](../../packages/apple/CONVENTION.md)
@@ -693,6 +693,7 @@ Version is managed in `openiap-versions.json`:
3. Run `swift test` to verify compatibility
**To bump Apple package version:**
+
```bash
./scripts/bump-version.sh [major|minor|patch|x.x.x]
```
@@ -708,7 +709,7 @@ swift build # Build package
## Google Package (packages/google)
-### Required Pre-Work
+### Required Pre-Work (Google)
Before writing or editing anything, **ALWAYS** review:
- [`packages/google/CONVENTION.md`](../../packages/google/CONVENTION.md)
@@ -1482,13 +1483,25 @@ await endConnection();
# Google Play Billing Library API Reference
-> Reference documentation for Google Play Billing Library 7.x
+> Reference documentation for Google Play Billing Library 8.x
> Adapt all patterns to match OpenIAP internal conventions.
## Overview
Google Play Billing Library enables in-app purchases and subscriptions on Android devices.
+## Version History
+
+| Version | Release Date | Key Features |
+|---------|--------------|--------------|
+| 8.0 | 2025-06-30 | Auto-reconnect, product-level status codes, one-time products with multiple offers, sub-response codes |
+| 8.1 | 2025-11-06 | Suspended subscriptions (`isSuspended`), `includeSuspended` parameter, pre-order details, product-level subscription replacement, `KEEP_EXISTING` mode |
+| 8.2 | 2025-12-09 | Billing Programs API (external content links, external offers), deprecates old External Offers API |
+| 8.2.1 | 2025-12-15 | Bug fix for `isBillingProgramAvailableAsync()` and `createBillingProgramReportingDetailsAsync()` |
+| 8.3 | 2025-12-23 | External Payments program (Japan only), developer billing options |
+
+**Current Version**: 8.3.0 (as of January 2026)
+
## Core Classes
### BillingClient
@@ -1499,9 +1512,24 @@ The main interface for communicating with Google Play Billing.
val billingClient = BillingClient.newBuilder(context)
.setListener(purchasesUpdatedListener)
.enablePendingPurchases()
+ // New in 8.0: Auto-reconnect on service disconnect
+ .enableAutoServiceReconnection()
.build()
```
+### Auto Service Reconnection (8.0+)
+
+```kotlin
+// Enables automatic reconnection when service disconnects
+BillingClient.newBuilder(context)
+ .enableAutoServiceReconnection()
+ .build()
+```
+
+When enabled, the library automatically re-establishes the connection if an API call is made while disconnected. This reduces `SERVICE_DISCONNECTED` errors.
+
+> **OpenIAP Note**: Auto-reconnection is **always enabled** internally since OpenIAP uses Billing Library 8.3.0+. No configuration needed.
+
### Connection Management
```kotlin
@@ -1719,13 +1747,126 @@ if (result.responseCode == BillingClient.BillingResponseCode.OK) {
- `PRICE_CHANGE_CONFIRMATION` - Price change confirmation
- `PRODUCT_DETAILS` - Product details API
+## Product-Level Status Codes (8.0+)
+
+In Billing Library 8.0+, `queryProductDetailsAsync()` returns products that couldn't be fetched with a status code explaining why.
+
+```kotlin
+billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
+ productDetailsList.forEach { productDetails ->
+ when (productDetails.productStatus) {
+ ProductDetails.ProductStatus.OK -> {
+ // Product fetched successfully
+ }
+ ProductDetails.ProductStatus.NOT_FOUND -> {
+ // SKU doesn't exist in Play Console
+ }
+ ProductDetails.ProductStatus.NO_OFFERS_AVAILABLE -> {
+ // User not eligible for any offers
+ }
+ }
+ }
+}
+```
+
+| Status | Description |
+|--------|-------------|
+| `OK` | Product fetched successfully |
+| `NOT_FOUND` | SKU doesn't exist in Play Console |
+| `NO_OFFERS_AVAILABLE` | User not eligible for any offers |
+
+## Suspended Subscriptions (8.1+)
+
+```kotlin
+val purchase: Purchase
+
+// Check if subscription is suspended due to billing issue
+if (purchase.isSuspended) {
+ // User's payment method failed
+ // Do NOT grant entitlements
+ // Direct user to subscription center to fix payment
+}
+```
+
+### Query Suspended Subscriptions (8.1+)
+
+```kotlin
+// Include suspended subscriptions in query results
+val params = QueryPurchasesParams.newBuilder()
+ .setProductType(BillingClient.ProductType.SUBS)
+ .setIncludeSuspended(true) // New in 8.1
+ .build()
+
+billingClient.queryPurchasesAsync(params) { billingResult, purchases ->
+ purchases.forEach { purchase ->
+ if (purchase.isSuspended) {
+ // Handle suspended subscription
+ }
+ }
+}
+```
+
+> **OpenIAP Note**: Use `includeSuspendedAndroid: true` in `PurchaseOptions` when calling `getAvailablePurchases()`. The `isSuspendedAndroid` field on purchases indicates suspension status.
+
+## Sub-Response Codes (8.0+)
+
+`BillingResult` includes a sub-response code for more granular error information:
+
+```kotlin
+val result = billingClient.launchBillingFlow(activity, params)
+when (result.subResponseCode) {
+ BillingResult.SUB_RESPONSE_CODE_INSUFFICIENT_FUNDS -> {
+ // User's payment method has insufficient funds
+ }
+ BillingResult.SUB_RESPONSE_CODE_USER_INELIGIBLE -> {
+ // User doesn't meet offer eligibility requirements
+ }
+}
+```
+
+| Sub-Response Code | Description |
+|-------------------|-------------|
+| `PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS` | User's payment method has insufficient funds |
+| `USER_INELIGIBLE` | User doesn't meet subscription offer eligibility |
+| `NO_APPLICABLE_SUB_RESPONSE_CODE` | No specific sub-code applies |
+
+## Subscription Product Replacement (8.1+)
+
+Product-level replacement parameters for subscription upgrades/downgrades:
+
+```kotlin
+val replacementParams = SubscriptionProductReplacementParams.newBuilder()
+ .setOldProductId("old_subscription_id")
+ .setReplacementMode(ReplacementMode.WITH_TIME_PRORATION)
+ .build()
+
+val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder()
+ .setProductDetails(newProductDetails)
+ .setOfferToken(offerToken)
+ .setSubscriptionProductReplacementParams(replacementParams) // New in 8.1
+ .build()
+```
+
+### Replacement Modes
+
+| Mode | Description |
+|------|-------------|
+| `WITH_TIME_PRORATION` | Immediate, expiration time prorated |
+| `CHARGE_PRORATED_PRICE` | Immediate, same billing cycle |
+| `CHARGE_FULL_PRICE` | Immediate, full price charged |
+| `WITHOUT_PRORATION` | Takes effect on old plan expiration |
+| `DEFERRED` | Deferred, no charge |
+| `KEEP_EXISTING` | Keep existing payment schedule (8.1+) |
+
## Best Practices
1. **Always acknowledge purchases** within 3 days or they will be refunded
2. **Verify purchases server-side** using Google Play Developer API
3. **Handle pending purchases** for payment methods that require additional steps
-4. **Reconnect on disconnect** - billing service can disconnect anytime
-5. **Cache product details** to avoid repeated queries
+4. **Auto-reconnect is enabled by default** in OpenIAP (8.0+)
+5. **Check product status codes** (8.0+) to understand why products weren't fetched
+6. **Check isSuspended** (8.1+) before granting entitlements
+7. **Cache product details** to avoid repeated queries
---
@@ -1735,7 +1876,7 @@ if (result.responseCode == BillingClient.BillingResponseCode.OK) {
# Meta Horizon IAP API Reference
> External reference for Meta Horizon Store in-app purchase APIs.
-> Source: https://developers.meta.com/horizon/documentation/
+> Source: [Meta Horizon Documentation](https://developers.meta.com/horizon/documentation/)
## Overview
@@ -1748,12 +1889,17 @@ Meta Horizon provides IAP functionality for Quest VR applications. There are two
| Library | Version | Compatible With |
|---------|---------|-----------------|
-| horizon-billing-compatibility | 1.1.1 | Google Play Billing **7.0** API |
-| Google Play Billing (Play flavor) | 8.3.0 | N/A |
-| react-native-iap | v14+ | Billing 7.0+ |
-| expo-iap | latest | Billing 7.0+ |
+| horizon-billing-compatibility | **1.1.1** (latest) | Google Play Billing **7.0** API |
+| Google Play Billing (Play flavor) | **8.3.0** (latest) | N/A |
+| react-native-iap | v14+ | Billing 7.0+, RN 0.79+, Kotlin 2.0+ |
+| expo-iap | latest | Billing 7.0+, Kotlin 2.0+ |
-**IMPORTANT**: Horizon Billing Compatibility SDK implements Google Play Billing **7.0** API surface, NOT 8.x. When writing shared code for both Play and Horizon flavors, use only APIs that exist in both Billing 7.0 and 8.x.
+**CRITICAL**: Horizon Billing Compatibility SDK implements Google Play Billing **7.0** API surface, NOT 8.x.
+
+When writing shared code for both Play and Horizon flavors:
+- Use only APIs that exist in **both** Billing 7.0 and 8.x
+- Horizon SDK does NOT support Billing 8.x features like auto-reconnect, product status codes, or `includeSuspended`
+- OpenIAP handles this automatically with flavor-specific implementations
### APIs Available in Both (Safe to use in shared code)
@@ -1766,9 +1912,15 @@ Meta Horizon provides IAP functionality for Quest VR applications. There are two
### APIs Only in Billing 8.x (DO NOT use in shared code)
-- `enableAutoServiceReconnection()` - Auto reconnect feature
-- Product-level status codes in `queryProductDetailsAsync()` response
-- One-time products with multiple offers
+- `enableAutoServiceReconnection()` - Auto reconnect feature (8.0+)
+- Product-level status codes in `queryProductDetailsAsync()` response (8.0+)
+- One-time products with multiple offers (8.0+)
+- Sub-response codes in `BillingResult` (8.0+)
+- `isSuspended` on Purchase (8.1+)
+- `includeSuspended` parameter in `QueryPurchasesParams` (8.1+)
+- `SubscriptionProductReplacementParams` (8.1+)
+- Billing Programs API (`isBillingProgramAvailableAsync`, etc.) (8.2+)
+- External Payments / Developer Billing Options (8.3+)
## Billing Compatibility SDK
@@ -1818,7 +1970,8 @@ Access token format: `OC|App_ID|App_Secret`
Verify that a user owns an item (app or add-on).
**Endpoint:**
-```
+
+```http
POST https://graph.oculus.com/$APP_ID/verify_entitlement
```
@@ -1826,7 +1979,7 @@ POST https://graph.oculus.com/$APP_ID/verify_entitlement
| Parameter | Type | Description |
|-----------|------|-------------|
-| `access_token` | string | `OC|App_ID|App_Secret` format |
+| `access_token` | string | `OC\|App_ID\|App_Secret` format |
| `user_id` | string | The user ID to verify |
| `sku` | string | (Optional) SKU for add-on verification |
@@ -1857,7 +2010,8 @@ curl -d "access_token=OC|$APP_ID|$APP_SECRET" \
Refund a DURABLE or CONSUMABLE entitlement (not yet consumed).
**Endpoint:**
-```
+
+```http
POST https://graph.oculus.com/$APP_ID/refund_iap_entitlement
```
@@ -1865,7 +2019,7 @@ POST https://graph.oculus.com/$APP_ID/refund_iap_entitlement
| Parameter | Type | Description |
|-----------|------|-------------|
-| `access_token` | string | `OC|App_ID|App_Secret` format |
+| `access_token` | string | `OC\|App_ID\|App_Secret` format |
| `user_id` | string | The user ID |
| `sku` | string | SKU of item to refund |
@@ -2407,6 +2561,30 @@ export default withIAPContext(Store);
This document provides external API reference for Apple's StoreKit 2 framework.
+## iOS 18+ Features
+
+| Feature | iOS Version | Description |
+|---------|-------------|-------------|
+| Win-back offers | iOS 18.0 | Re-engage churned subscribers |
+| Consumable transaction history | iOS 18.0 | History includes finished consumables |
+| Billing issue messages | iOS 18.0 | Automatic billing issue notifications via StoreKit Message |
+| UI context for purchases | iOS 18.2 | Required for proper payment sheet display |
+| External purchase notice | iOS 18.2 | `presentExternalPurchaseNoticeSheetIOS` |
+| `appTransactionID` | iOS 18.4 | Globally unique app transaction identifier (back-deployed to iOS 15) |
+| `originalPlatform` | iOS 18.4 | Original purchase platform (back-deployed to iOS 15) |
+| `Offer.Period` | iOS 18.4 | Offer period information |
+| `advancedCommerceInfo` | iOS 18.4 | Advanced Commerce API data |
+| Expanded offer codes | iOS 18.4 | For consumables/non-consumables |
+| JWS promotional offers | WWDC 2025 | New `promotionalOffer` purchase option with JWS format |
+| `introductoryOfferEligibility` | WWDC 2025 | Set eligibility via purchase option |
+
+### WWDC 2025 Updates
+
+- **SubscriptionStatus by Transaction ID**: Query subscription status using any transaction ID
+- **JWS-based promotional offers**: New `promotionalOffer` purchase option with compact JWS string
+- **Introductory offer eligibility**: Override eligibility check with `introductoryOfferEligibility` purchase option
+- Both new purchase options are back-deployed to iOS 15
+
## Product
A type that describes an in-app purchase product.
@@ -2514,6 +2692,124 @@ static func beginRefundRequest(for transactionID: UInt64, in scene: UIWindowScen
Begins a refund request for a transaction.
+## Win-Back Offers (iOS 18+)
+
+Win-back offers are a new offer type to re-engage churned subscribers.
+
+### Automatic Presentation
+
+StoreKit Message automatically presents win-back offers when a user is eligible:
+
+```swift
+// Message reason for win-back offers
+StoreKit.Message.Reason.winBackOffer
+```
+
+### Manual Application
+
+Apply a win-back offer during purchase:
+
+```swift
+let product: Product
+let winBackOffer: Product.SubscriptionOffer
+
+let result = try await product.purchase(options: [
+ .winBackOffer(winBackOffer)
+])
+```
+
+### Checking Eligibility
+
+```swift
+// Win-back offers are available in subscription.promotionalOffers
+// with type == .winBack
+let winBackOffers = product.subscription?.promotionalOffers.filter {
+ $0.type == .winBack
+}
+```
+
+### RenewalInfo
+
+Win-back offer information is available in renewal info:
+
+```swift
+let renewalInfo: Product.SubscriptionInfo.RenewalInfo
+
+// Check if win-back offer is applied to next renewal
+if renewalInfo.renewalOfferType == .winBack {
+ // Win-back offer will be applied
+}
+```
+
+## UI Context for Purchases (iOS 18.2+)
+
+Beginning in iOS 18.2, purchase methods require a UI context to properly display payment sheets:
+
+```swift
+// iOS/iPadOS/tvOS/visionOS: UIViewController
+let result = try await product.purchase(confirmIn: viewController)
+
+// macOS: NSWindow
+let result = try await product.purchase(confirmIn: window)
+
+// watchOS: No UI context required
+```
+
+> **OpenIAP Note**: UI context is handled automatically in OpenIAP using the active window scene.
+
+## AppTransaction Updates (iOS 18.4+)
+
+```swift
+let appTransaction = try await AppTransaction.shared
+
+// New in iOS 18.4 (back-deployed to iOS 15)
+let appTransactionID = appTransaction.appTransactionID // Globally unique per Apple Account
+let originalPlatform = appTransaction.originalPlatform // Original purchase platform
+```
+
+### appTransactionID
+
+- Globally unique identifier for each Apple Account that downloads your app
+- Remains consistent across redownloads, refunds, repurchases, and storefront changes
+- Works with Family Sharing (each family member gets unique ID)
+- Back-deployed to iOS 15
+
+## Advanced Commerce API (iOS 18.4+)
+
+For apps with large product catalogs:
+
+```swift
+// Check if product has advanced commerce info
+if let advancedInfo = product.advancedCommerceInfo {
+ // Handle large catalog monetization
+}
+```
+
+## External Purchase Support (iOS 18.2+)
+
+### Present External Purchase Notice
+
+```swift
+// Check if external purchase notice can be presented
+if await ExternalPurchase.canPresent {
+ let result = try await ExternalPurchase.presentNoticeSheet()
+ switch result {
+ case .continue:
+ // User wants to continue to external purchase
+ case .dismissed:
+ // User dismissed the notice
+ }
+}
+```
+
+### Present External Purchase Link
+
+```swift
+let result = try await ExternalPurchase.open(url: externalURL)
+```
+
+> **OpenIAP Note**: `presentExternalPurchaseNoticeSheetIOS` and `presentExternalPurchaseLinkIOS` are available in the iOS package.
+
---
diff --git a/knowledge/external/google-billing-api.md b/knowledge/external/google-billing-api.md
index 12288abd..a1bfddd1 100644
--- a/knowledge/external/google-billing-api.md
+++ b/knowledge/external/google-billing-api.md
@@ -1,12 +1,24 @@
# Google Play Billing Library API Reference
-> Reference documentation for Google Play Billing Library 7.x
+> Reference documentation for Google Play Billing Library 8.x
> Adapt all patterns to match OpenIAP internal conventions.
## Overview
Google Play Billing Library enables in-app purchases and subscriptions on Android devices.
+## Version History
+
+| Version | Release Date | Key Features |
+|---------|--------------|--------------|
+| 8.0 | 2025-06-30 | Auto-reconnect, product-level status codes, one-time products with multiple offers, sub-response codes |
+| 8.1 | 2025-11-06 | Suspended subscriptions (`isSuspended`), `includeSuspended` parameter, pre-order details, product-level subscription replacement, `KEEP_EXISTING` mode |
+| 8.2 | 2025-12-09 | Billing Programs API (external content links, external offers), deprecates old External Offers API |
+| 8.2.1 | 2025-12-15 | Bug fix for `isBillingProgramAvailableAsync()` and `createBillingProgramReportingDetailsAsync()` |
+| 8.3 | 2025-12-23 | External Payments program (Japan only), developer billing options |
+
+**Current Version**: 8.3.0 (as of January 2026)
+
## Core Classes
### BillingClient
@@ -17,9 +29,24 @@ The main interface for communicating with Google Play Billing.
val billingClient = BillingClient.newBuilder(context)
.setListener(purchasesUpdatedListener)
.enablePendingPurchases()
+ // New in 8.0: Auto-reconnect on service disconnect
+ .enableAutoServiceReconnection()
+ .build()
+```
+
+### Auto Service Reconnection (8.0+)
+
+```kotlin
+// Enables automatic reconnection when service disconnects
+BillingClient.newBuilder(context)
+ .enableAutoServiceReconnection()
.build()
```
+When enabled, the library automatically re-establishes the connection if an API call is made while disconnected. This reduces `SERVICE_DISCONNECTED` errors.
+
+> **OpenIAP Note**: Auto-reconnection is **always enabled** internally since OpenIAP uses Billing Library 8.3.0+. No configuration needed.
+
### Connection Management
```kotlin
@@ -237,10 +264,123 @@ if (result.responseCode == BillingClient.BillingResponseCode.OK) {
- `PRICE_CHANGE_CONFIRMATION` - Price change confirmation
- `PRODUCT_DETAILS` - Product details API
+## Product-Level Status Codes (8.0+)
+
+In Billing Library 8.0+, `queryProductDetailsAsync()` returns products that couldn't be fetched with a status code explaining why.
+
+```kotlin
+billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
+ productDetailsList.forEach { productDetails ->
+ when (productDetails.productStatus) {
+ ProductDetails.ProductStatus.OK -> {
+ // Product fetched successfully
+ }
+ ProductDetails.ProductStatus.NOT_FOUND -> {
+ // SKU doesn't exist in Play Console
+ }
+ ProductDetails.ProductStatus.NO_OFFERS_AVAILABLE -> {
+ // User not eligible for any offers
+ }
+ }
+ }
+}
+```
+
+| Status | Description |
+|--------|-------------|
+| `OK` | Product fetched successfully |
+| `NOT_FOUND` | SKU doesn't exist in Play Console |
+| `NO_OFFERS_AVAILABLE` | User not eligible for any offers |
+
+## Suspended Subscriptions (8.1+)
+
+```kotlin
+val purchase: Purchase
+
+// Check if subscription is suspended due to billing issue
+if (purchase.isSuspended) {
+ // User's payment method failed
+ // Do NOT grant entitlements
+ // Direct user to subscription center to fix payment
+}
+```
+
+### Query Suspended Subscriptions (8.1+)
+
+```kotlin
+// Include suspended subscriptions in query results
+val params = QueryPurchasesParams.newBuilder()
+ .setProductType(BillingClient.ProductType.SUBS)
+ .setIncludeSuspended(true) // New in 8.1
+ .build()
+
+billingClient.queryPurchasesAsync(params) { billingResult, purchases ->
+ purchases.forEach { purchase ->
+ if (purchase.isSuspended) {
+ // Handle suspended subscription
+ }
+ }
+}
+```
+
+> **OpenIAP Note**: Use `includeSuspendedAndroid: true` in `PurchaseOptions` when calling `getAvailablePurchases()`. The `isSuspendedAndroid` field on purchases indicates suspension status.
+
+## Sub-Response Codes (8.0+)
+
+`BillingResult` includes a sub-response code for more granular error information:
+
+```kotlin
+val result = billingClient.launchBillingFlow(activity, params)
+when (result.subResponseCode) {
+ BillingResult.SUB_RESPONSE_CODE_INSUFFICIENT_FUNDS -> {
+ // User's payment method has insufficient funds
+ }
+ BillingResult.SUB_RESPONSE_CODE_USER_INELIGIBLE -> {
+ // User doesn't meet offer eligibility requirements
+ }
+}
+```
+
+| Sub-Response Code | Description |
+|-------------------|-------------|
+| `PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS` | User's payment method has insufficient funds |
+| `USER_INELIGIBLE` | User doesn't meet subscription offer eligibility |
+| `NO_APPLICABLE_SUB_RESPONSE_CODE` | No specific sub-code applies |
+
+## Subscription Product Replacement (8.1+)
+
+Product-level replacement parameters for subscription upgrades/downgrades:
+
+```kotlin
+val replacementParams = SubscriptionProductReplacementParams.newBuilder()
+ .setOldProductId("old_subscription_id")
+ .setReplacementMode(ReplacementMode.WITH_TIME_PRORATION)
+ .build()
+
+val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder()
+ .setProductDetails(newProductDetails)
+ .setOfferToken(offerToken)
+ .setSubscriptionProductReplacementParams(replacementParams) // New in 8.1
+ .build()
+```
+
+### Replacement Modes
+
+| Mode | Description |
+|------|-------------|
+| `WITH_TIME_PRORATION` | Immediate, expiration time prorated |
+| `CHARGE_PRORATED_PRICE` | Immediate, same billing cycle |
+| `CHARGE_FULL_PRICE` | Immediate, full price charged |
+| `WITHOUT_PRORATION` | Takes effect on old plan expiration |
+| `DEFERRED` | Deferred, no charge |
+| `KEEP_EXISTING` | Keep existing payment schedule (8.1+) |
+
## Best Practices
1. **Always acknowledge purchases** within 3 days or they will be refunded
2. **Verify purchases server-side** using Google Play Developer API
3. **Handle pending purchases** for payment methods that require additional steps
-4. **Reconnect on disconnect** - billing service can disconnect anytime
-5. **Cache product details** to avoid repeated queries
+4. **Auto-reconnect is enabled by default** in OpenIAP (8.0+)
+5. **Check product status codes** (8.0+) to understand why products weren't fetched
+6. **Check isSuspended** (8.1+) before granting entitlements
+7. **Cache product details** to avoid repeated queries
diff --git a/knowledge/external/horizon-api.md b/knowledge/external/horizon-api.md
index cdbebf17..540d1a68 100644
--- a/knowledge/external/horizon-api.md
+++ b/knowledge/external/horizon-api.md
@@ -14,12 +14,17 @@ Meta Horizon provides IAP functionality for Quest VR applications. There are two
| Library | Version | Compatible With |
|---------|---------|-----------------|
-| horizon-billing-compatibility | 1.1.1 | Google Play Billing **7.0** API |
-| Google Play Billing (Play flavor) | 8.3.0 | N/A |
-| react-native-iap | v14+ | Billing 7.0+ |
-| expo-iap | latest | Billing 7.0+ |
+| horizon-billing-compatibility | **1.1.1** (latest) | Google Play Billing **7.0** API |
+| Google Play Billing (Play flavor) | **8.3.0** (latest) | N/A |
+| react-native-iap | v14+ | Billing 7.0+, RN 0.79+, Kotlin 2.0+ |
+| expo-iap | latest | Billing 7.0+, Kotlin 2.0+ |
-**IMPORTANT**: Horizon Billing Compatibility SDK implements Google Play Billing **7.0** API surface, NOT 8.x. When writing shared code for both Play and Horizon flavors, use only APIs that exist in both Billing 7.0 and 8.x.
+**CRITICAL**: Horizon Billing Compatibility SDK implements Google Play Billing **7.0** API surface, NOT 8.x.
+
+When writing shared code for both Play and Horizon flavors:
+- Use only APIs that exist in **both** Billing 7.0 and 8.x
+- Horizon SDK does NOT support Billing 8.x features like auto-reconnect, product status codes, or `includeSuspended`
+- OpenIAP handles this automatically with flavor-specific implementations
### APIs Available in Both (Safe to use in shared code)
@@ -32,9 +37,15 @@ Meta Horizon provides IAP functionality for Quest VR applications. There are two
### APIs Only in Billing 8.x (DO NOT use in shared code)
-- `enableAutoServiceReconnection()` - Auto reconnect feature
-- Product-level status codes in `queryProductDetailsAsync()` response
-- One-time products with multiple offers
+- `enableAutoServiceReconnection()` - Auto reconnect feature (8.0+)
+- Product-level status codes in `queryProductDetailsAsync()` response (8.0+)
+- One-time products with multiple offers (8.0+)
+- Sub-response codes in `BillingResult` (8.0+)
+- `isSuspended` on Purchase (8.1+)
+- `includeSuspended` parameter in `QueryPurchasesParams` (8.1+)
+- `SubscriptionProductReplacementParams` (8.1+)
+- Billing Programs API (`isBillingProgramAvailableAsync`, etc.) (8.2+)
+- External Payments / Developer Billing Options (8.3+)
## Billing Compatibility SDK
diff --git a/knowledge/external/storekit2-api.md b/knowledge/external/storekit2-api.md
index 76147680..dbf9df0a 100644
--- a/knowledge/external/storekit2-api.md
+++ b/knowledge/external/storekit2-api.md
@@ -2,6 +2,30 @@
This document provides external API reference for Apple's StoreKit 2 framework.
+## iOS 18+ Features
+
+| Feature | iOS Version | Description |
+|---------|-------------|-------------|
+| Win-back offers | iOS 18.0 | Re-engage churned subscribers |
+| Consumable transaction history | iOS 18.0 | History includes finished consumables |
+| Billing issue messages | iOS 18.0 | Automatic billing issue notifications via StoreKit Message |
+| UI context for purchases | iOS 18.2 | Required for proper payment sheet display |
+| External purchase notice | iOS 18.2 | `presentExternalPurchaseNoticeSheetIOS` |
+| `appTransactionID` | iOS 18.4 | Globally unique app transaction identifier (back-deployed to iOS 15) |
+| `originalPlatform` | iOS 18.4 | Original purchase platform (back-deployed to iOS 15) |
+| `Offer.Period` | iOS 18.4 | Offer period information |
+| `advancedCommerceInfo` | iOS 18.4 | Advanced Commerce API data |
+| Expanded offer codes | iOS 18.4 | For consumables/non-consumables |
+| JWS promotional offers | WWDC 2025 | New `promotionalOffer` purchase option with JWS format |
+| `introductoryOfferEligibility` | WWDC 2025 | Set eligibility via purchase option |
+
+### WWDC 2025 Updates
+
+- **SubscriptionStatus by Transaction ID**: Query subscription status using any transaction ID
+- **JWS-based promotional offers**: New `promotionalOffer` purchase option with compact JWS string
+- **Introductory offer eligibility**: Override eligibility check with `introductoryOfferEligibility` purchase option
+- Both new purchase options are back-deployed to iOS 15
+
## Product
A type that describes an in-app purchase product.
@@ -108,3 +132,121 @@ static func beginRefundRequest(for transactionID: UInt64, in scene: UIWindowScen
```
Begins a refund request for a transaction.
+
+## Win-Back Offers (iOS 18+)
+
+Win-back offers are a new offer type to re-engage churned subscribers.
+
+### Automatic Presentation
+
+StoreKit Message automatically presents win-back offers when a user is eligible:
+
+```swift
+// Message reason for win-back offers
+StoreKit.Message.Reason.winBackOffer
+```
+
+### Manual Application
+
+Apply a win-back offer during purchase:
+
+```swift
+let product: Product
+let winBackOffer: Product.SubscriptionOffer
+
+let result = try await product.purchase(options: [
+ .winBackOffer(winBackOffer)
+])
+```
+
+### Checking Eligibility
+
+```swift
+// Win-back offers are available in subscription.promotionalOffers
+// with type == .winBack
+let winBackOffers = product.subscription?.promotionalOffers.filter {
+ $0.type == .winBack
+}
+```
+
+### RenewalInfo
+
+Win-back offer information is available in renewal info:
+
+```swift
+let renewalInfo: Product.SubscriptionInfo.RenewalInfo
+
+// Check if win-back offer is applied to next renewal
+if renewalInfo.renewalOfferType == .winBack {
+ // Win-back offer will be applied
+}
+```
+
+## UI Context for Purchases (iOS 18.2+)
+
+Beginning in iOS 18.2, purchase methods require a UI context to properly display payment sheets:
+
+```swift
+// iOS/iPadOS/tvOS/visionOS: UIViewController
+let result = try await product.purchase(confirmIn: viewController)
+
+// macOS: NSWindow
+let result = try await product.purchase(confirmIn: window)
+
+// watchOS: No UI context required
+```
+
+> **OpenIAP Note**: UI context is handled automatically in OpenIAP using the active window scene.
+
+## AppTransaction Updates (iOS 18.4+)
+
+```swift
+let appTransaction = try await AppTransaction.shared
+
+// New in iOS 18.4 (back-deployed to iOS 15)
+let appTransactionID = appTransaction.appTransactionID // Globally unique per Apple Account
+let originalPlatform = appTransaction.originalPlatform // Original purchase platform
+```
+
+### appTransactionID
+
+- Globally unique identifier for each Apple Account that downloads your app
+- Remains consistent across redownloads, refunds, repurchases, and storefront changes
+- Works with Family Sharing (each family member gets unique ID)
+- Back-deployed to iOS 15
+
+## Advanced Commerce API (iOS 18.4+)
+
+For apps with large product catalogs:
+
+```swift
+// Check if product has advanced commerce info
+if let advancedInfo = product.advancedCommerceInfo {
+ // Handle large catalog monetization
+}
+```
+
+## External Purchase Support (iOS 18.2+)
+
+### Present External Purchase Notice
+
+```swift
+// Check if external purchase notice can be presented
+if await ExternalPurchase.canPresent {
+ let result = try await ExternalPurchase.presentNoticeSheet()
+ switch result {
+ case .continue:
+ // User wants to continue to external purchase
+ case .dismissed:
+ // User dismissed the notice
+ }
+}
+```
+
+### Present External Purchase Link
+
+```swift
+let result = try await ExternalPurchase.open(url: externalURL)
+```
+
+> **OpenIAP Note**: `presentExternalPurchaseNoticeSheetIOS` and `presentExternalPurchaseLinkIOS` are available in the iOS package.
From 9b285811213d66f68773b8272814c68defb7f0af Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sun, 18 Jan 2026 22:13:29 +0900
Subject: [PATCH 07/11] chore(skills): enhance sync and audit workflows
- Add documentation checklist to all sync-*.md files
- Add example code requirements to audit-code.md
- Add local dev testing section to sync-expo-iap.md
- Create commit.md skill for structured commit workflows
- Improve sync workflow instructions with verification steps
Co-Authored-By: Claude Opus 4.5
---
.claude/commands/audit-code.md | 189 ++++++++++++-
.claude/commands/commit.md | 320 ++++++++++++++++++++++
.claude/commands/sync-all-platforms.md | 55 +++-
.claude/commands/sync-expo-iap.md | 156 ++++++++++-
.claude/commands/sync-flutter-iap.md | 91 +++++-
.claude/commands/sync-godot-iap.md | 80 +++++-
.claude/commands/sync-kmp-iap.md | 85 +++++-
.claude/commands/sync-react-native-iap.md | 91 +++++-
8 files changed, 1032 insertions(+), 35 deletions(-)
create mode 100644 .claude/commands/commit.md
diff --git a/.claude/commands/audit-code.md b/.claude/commands/audit-code.md
index 61af51a7..f181c801 100644
--- a/.claude/commands/audit-code.md
+++ b/.claude/commands/audit-code.md
@@ -15,7 +15,9 @@ Automated workflow to check and fix code based on knowledge rules and latest pla
↓
5. Fix issues found
↓
-6. Verify fixes
+6. Update documentation
+ ↓
+7. Verify fixes
```
## Steps
@@ -147,13 +149,183 @@ After identifying issues:
3. Fix the code to comply with the rule
4. For missing features: add to roadmap or implement
-### 7. Update External Docs
+### 7. Update Documentation
+
+When new features are implemented or APIs change, update ALL relevant documentation:
-If new API features are found, update knowledge/external/:
+#### 7a. Knowledge Base (knowledge/external/)
+
+Update external API reference docs:
- `google-billing-api.md` - Add new Google Play Billing features
- `storekit2-api.md` - Add new StoreKit 2 features
- `horizon-api.md` - Add new Meta Horizon Billing features, version compatibility
+#### 7b. User Documentation (packages/docs/)
+
+Update the documentation site for users:
+
+**Release Notes (REQUIRED):**
+- `src/pages/docs/updates/notes.tsx` - Add release notes for next patch version
+- Check current version in `openiap-versions.json` and increment patch
+- Document ALL changes: new features, bug fixes, breaking changes
+- Add entry at the TOP of `allNotes` array (newest first)
+
+Example notes.tsx entry:
+```typescript
+// Add to TOP of allNotes array in notes.tsx
+{
+ id: 'gql-1-3-13-google-1-3-24-apple-1-3-11', // kebab-case id
+ date: new Date('2026-01-20'),
+ element: (
+
+
+ 📅 openiap-gql v1.3.13 / openiap-google v1.3.24 / openiap-apple v1.3.11 - Feature Name
+
+
+
iOS - Win-Back Offers (iOS 18+):
+
+ winBackOffer - New field in RequestSubscriptionIosProps
+ - Re-engage churned subscribers with discounts
+
+
+
Android - Product Status Codes (Billing 8.0+):
+
+ ProductStatusAndroid - New enum (OK, NOT_FOUND, NO_OFFERS_AVAILABLE)
+ productStatusAndroid - New field on ProductAndroid
+
+
+
References:
+
+
+ ),
+},
+```
+
+**API Reference Pages:**
+- `src/pages/docs/apis/*.tsx` - Update function signatures, parameters, return types
+- Add new functions to appropriate API pages (index.tsx, ios.tsx, android.tsx, etc.)
+- Update deprecated function notices
+
+**Type Documentation:**
+- `src/pages/docs/types/*.tsx` - Update type definitions
+- Add new types (enums, interfaces, input types)
+- Document new fields on existing types
+- Key files: product.tsx, purchase.tsx, offer.tsx, alternative.tsx, etc.
+
+**Feature Documentation:**
+- `src/pages/docs/features/*.tsx` - Add new feature pages if implementing major functionality
+- Update existing feature pages with new options/parameters
+- Include code examples for new features
+
+#### 7c. Example Apps (REQUIRED)
+
+Update example apps to demonstrate new features:
+
+**iOS Example** (`packages/apple/Example/OpenIapExample/`):
+- `Screens/` - Add new screens or update existing ones
+- `Screens/uis/` - Add UI components for new features
+- Key files:
+ - `PurchaseFlowScreen.swift` - Purchase flow examples
+ - `SubscriptionFlowScreen.swift` - Subscription examples
+ - `AlternativeBillingScreen.swift` - External purchase examples
+ - `AvailablePurchasesScreen.swift` - Purchase history examples
+
+**Android Example** (`packages/google/Example/src/main/java/dev/hyo/martie/`):
+- `screens/` - Add new screens or update existing ones
+- `screens/uis/` - Add UI components for new features
+- Key files:
+ - `PurchaseFlowScreen.kt` - Purchase flow examples
+ - `SubscriptionFlowScreen.kt` - Subscription examples
+ - `AlternativeBillingScreen.kt` - External purchase examples
+ - `AvailablePurchasesScreen.kt` - Purchase history examples
+
+**Example Code Guidelines:**
+- Demonstrate ALL new API features with working code
+- Show both success and error handling
+- Include comments explaining the feature
+- Use realistic SKU names and user flows
+- Test on actual devices before committing
+
+**Example for Win-Back Offer (iOS):**
+```swift
+// In SubscriptionFlowScreen.swift
+Button("Apply Win-Back Offer") {
+ Task {
+ let props = RequestSubscriptionIosProps(
+ sku: "premium_monthly",
+ winBackOffer: WinBackOfferInputIOS(offerId: "winback_50_off")
+ )
+ // ... purchase flow
+ }
+}
+```
+
+**Example for Product Status (Android):**
+```kotlin
+// In AllProductsScreen.kt
+product.productStatusAndroid?.let { status ->
+ when (status) {
+ ProductStatusAndroid.Ok -> { /* Show product */ }
+ ProductStatusAndroid.NotFound -> { /* Show error */ }
+ ProductStatusAndroid.NoOffersAvailable -> { /* Show ineligible message */ }
+ else -> { /* Handle unknown */ }
+ }
+}
+```
+
+#### 7d. Documentation Checklist
+
+For each new feature implemented:
+
+- [ ] **Release notes** - Entry added to `notes.tsx` with next patch version
+- [ ] **API docs** - Function added to correct API page with signature, params, return type
+- [ ] **Type docs** - New types documented with all fields explained
+- [ ] **Example apps** - Working examples in iOS and Android example apps
+- [ ] **Code examples** - Inline code examples in documentation
+- [ ] **Platform notes** - Version requirements (e.g., "iOS 18+", "Billing 8.0+")
+- [ ] **Cross-references** - Links between related functions/types
+- [ ] **Search** - New items added to search index
+
+#### 7e. Documentation Examples
+
+**New Function (e.g., win-back offer):**
+```mdx
+## requestSubscription
+
+### Parameters
+
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| sku | string | ✅ | Product SKU |
+| winBackOffer | WinBackOfferInputIOS | ❌ | Win-back offer (iOS 18+) |
+
+### Win-Back Offers (iOS 18+)
+
+Win-back offers re-engage churned subscribers:
+
+```typescript
+await requestSubscription({
+ sku: 'premium_monthly',
+ winBackOffer: { offerId: 'winback_50_off' }
+});
+```
+```
+
+**New Type:**
+```mdx
+## ProductStatusAndroid
+
+Product fetch status codes (Billing 8.0+).
+
+| Value | Description |
+|-------|-------------|
+| OK | Product fetched successfully |
+| NOT_FOUND | SKU doesn't exist |
+| NO_OFFERS_AVAILABLE | User not eligible |
+```
+
### 8. Final Verification
```bash
@@ -191,5 +363,12 @@ After running audit, you should have:
1. **Rule Violations Report** - List of internal rule violations found and fixed
2. **Feature Gap Report** - Missing platform features with implementation status
-3. **Updated External Docs** - knowledge/external/ updated with latest API info
-4. **Roadmap Items** - New features to implement (if any)
+3. **Updated Knowledge Base** - knowledge/external/ updated with latest API info
+4. **Updated User Docs** - packages/docs/ updated:
+ - `notes.tsx` - Release notes for next version
+ - API reference pages updated
+ - Type documentation updated
+5. **Updated Example Apps** - packages/*/Example/ updated:
+ - iOS example demonstrating new features
+ - Android example demonstrating new features
+6. **Roadmap Items** - New features to implement (if any)
diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md
new file mode 100644
index 00000000..3bb23762
--- /dev/null
+++ b/.claude/commands/commit.md
@@ -0,0 +1,320 @@
+# Commit Changes
+
+Complete workflow: branch → commit → push → PR
+
+## Usage
+
+```
+/commit [options]
+```
+
+**Options:**
+- `--push` or `-p`: Push to remote after commit
+- `--pr`: Create PR after push
+- `--all` or `-a`: Commit all changes at once
+- ``: Commit only specific path (e.g., `packages/gql`)
+
+## Examples
+
+```bash
+# Full workflow: commit gql spec, push, create PR
+/commit packages/gql/src/*.graphql --pr
+
+# Commit all and create PR
+/commit --all --pr
+
+# Just commit specific path
+/commit packages/apple
+```
+
+## Complete Workflow
+
+### 1. Check Branch
+
+```bash
+# Check current branch
+git branch --show-current
+```
+
+**If on `main`** → Create a feature branch first:
+```bash
+git checkout -b feat/
+```
+
+**If NOT on `main`** → Proceed with commits directly.
+
+**Branch naming conventions:**
+- `feat/` - New features
+- `fix/` - Bug fixes
+- `docs/` - Documentation only
+- `chore/` - Maintenance tasks
+
+### 2. Check Current Status
+
+```bash
+git status
+git diff --name-only
+```
+
+### 3. Stage Changes
+
+**GQL schema only (FIRST COMMIT):**
+```bash
+git add packages/gql/src/*.graphql
+```
+
+**Generated types (SECOND COMMIT):**
+```bash
+git add packages/gql/src/generated/
+```
+
+**Specific path:**
+```bash
+git add
+```
+
+**All changes:**
+```bash
+git add .
+```
+
+### 4. Review Staged Changes
+
+```bash
+git diff --cached --stat
+git diff --cached --name-only
+```
+
+### 5. Create Commit
+
+Follow conventional commit format:
+
+```bash
+git commit -m "$(cat <<'EOF'
+():
+
+
+
+Co-Authored-By: Claude Opus 4.5
+EOF
+)"
+```
+
+**Commit Types:**
+| Type | Description |
+|------|-------------|
+| `feat` | New feature |
+| `fix` | Bug fix |
+| `docs` | Documentation only |
+| `refactor` | Code refactoring |
+| `chore` | Maintenance tasks |
+| `test` | Adding/updating tests |
+
+**Scope Examples:**
+- `gql` - GraphQL schema changes
+- `apple` - iOS/macOS package
+- `google` - Android package
+- `docs` - Documentation site
+- `skills` - Claude skills/commands
+
+### 6. Push to Remote
+
+```bash
+git push -u origin
+```
+
+### 7. Create Pull Request
+
+```bash
+gh pr create --title "(): " --body "$(cat <<'EOF'
+## Summary
+
+<1-3 bullet points describing changes>
+
+## Changes
+
+###
+- Change 1
+- Change 2
+
+###
+- Change 1
+
+## Test plan
+
+- [ ] Type check passes
+- [ ] Tests pass
+- [ ] Build succeeds
+
+🤖 Generated with [Claude Code](https://claude.ai/code)
+EOF
+)"
+```
+
+---
+
+## Commit Order (CRITICAL)
+
+When making cross-package changes, commit in this order:
+
+| Order | Path | Description |
+|-------|------|-------------|
+| 1 | `packages/gql/src/*.graphql` | GraphQL schema ONLY (no generated types) |
+| 2 | `packages/gql/src/generated/` | Generated types (after schema review) |
+| 3 | `packages/apple/` | iOS implementation |
+| 4 | `packages/google/` | Android implementation |
+| 5 | `packages/docs/` | Documentation updates |
+| 6 | `.claude/commands/` | Skill/workflow updates |
+| 7 | `knowledge/` | Knowledge base updates |
+
+**IMPORTANT - First Commit Must Be GQL Spec Only:**
+```bash
+# Stage ONLY .graphql files (not generated/)
+git add packages/gql/src/*.graphql
+
+# Verify - should only show .graphql files
+git diff --cached --name-only
+# packages/gql/src/type-android.graphql
+# packages/gql/src/type-ios.graphql
+# packages/gql/src/type.graphql
+
+# Commit schema changes
+git commit -m "feat(gql): add new types..."
+```
+
+This order allows:
+- API schema to be reviewed first before any implementation
+- Generated types committed after schema approval
+- Platform implementations to follow the approved schema
+- Documentation to reflect final implementation
+
+---
+
+## Example Commit Messages
+
+**GQL schema update:**
+```
+feat(gql): add win-back offer and product status types
+
+iOS (StoreKit 2):
+- WinBackOfferInputIOS for iOS 18+ win-back offers
+- PromotionalOfferJWSInputIOS for WWDC 2025 JWS format
+- SubscriptionOfferTypeIOS.WinBack enum value
+
+Android (Billing 8.0+):
+- ProductStatusAndroid enum (OK, NOT_FOUND, NO_OFFERS_AVAILABLE)
+- productStatusAndroid field on ProductAndroid
+
+Co-Authored-By: Claude Opus 4.5
+```
+
+**Generated types:**
+```
+chore(gql): regenerate types for all platforms
+
+Regenerate TypeScript, Swift, Kotlin, Dart, GDScript types
+from updated GraphQL schema.
+
+Co-Authored-By: Claude Opus 4.5
+```
+
+**iOS implementation:**
+```
+feat(apple): implement win-back offers and JWS promotional offers
+
+- Add winBackOffer support in requestPurchase/requestSubscription
+- Add promotionalOfferJWS for new signature format (iOS 15+)
+- Add introductoryOfferEligibility override option
+- Update StoreKitTypesBridge for new offer types
+
+Co-Authored-By: Claude Opus 4.5
+```
+
+**Documentation update:**
+```
+docs: add release notes and type documentation
+
+- Add release notes for gql 1.3.13, google 1.3.24, apple 1.3.11
+- Document ProductStatusAndroid enum in product.tsx
+- Document WinBack offer type in offer.tsx
+- Update llms.txt with new API information
+
+Co-Authored-By: Claude Opus 4.5
+```
+
+**Skills update:**
+```
+chore(skills): enhance sync and audit workflows
+
+- Add documentation checklist to all sync-*.md files
+- Add example code requirements to audit-code.md
+- Add local dev testing section to sync-expo-iap.md
+- Create commit skill for structured commits
+
+Co-Authored-By: Claude Opus 4.5
+```
+
+---
+
+## Example PR Body
+
+```markdown
+## Summary
+
+- Add Win-Back offers support for iOS 18+
+- Add ProductStatusAndroid for Billing 8.0+ status codes
+- Add JWS promotional offers for WWDC 2025
+
+## Changes
+
+### GraphQL Schema (packages/gql)
+- `WinBackOfferInputIOS` - Win-back offer input type
+- `ProductStatusAndroid` - Product fetch status enum
+- `PromotionalOfferJWSInputIOS` - JWS format promo offers
+
+### iOS (packages/apple)
+- Implement win-back offer handling in purchase flow
+- Add JWS promotional offer support (back-deployed to iOS 15)
+- Add introductory offer eligibility override
+
+### Android (packages/google)
+- Map ProductStatusAndroid from BillingResult
+- Return status in fetchProducts response
+
+### Documentation (packages/docs)
+- Release notes for v1.3.13
+- Type documentation updates
+- Example code updates
+
+## Test plan
+
+- [x] `swift build` passes
+- [x] `./gradlew :openiap:compilePlayDebugKotlin` passes
+- [x] `./gradlew :openiap:compileHorizonDebugKotlin` passes
+- [x] `bun run typecheck` passes (docs)
+
+🤖 Generated with [Claude Code](https://claude.ai/code)
+```
+
+---
+
+## Quick Reference
+
+```bash
+# Full workflow from main
+git checkout -b feat/my-feature
+git add packages/gql/src/*.graphql
+git commit -m "feat(gql): add new types"
+git add packages/gql/src/generated/
+git commit -m "chore(gql): regenerate types"
+git add packages/apple/
+git commit -m "feat(apple): implement new types"
+git add packages/google/
+git commit -m "feat(google): implement new types"
+git add packages/docs/
+git commit -m "docs: update documentation"
+git add .
+git commit -m "chore: update skills and knowledge"
+git push -u origin feat/my-feature
+gh pr create --title "feat: add new feature" --body "..."
+```
diff --git a/.claude/commands/sync-all-platforms.md b/.claude/commands/sync-all-platforms.md
index 9d481b09..7cd2a5ff 100644
--- a/.claude/commands/sync-all-platforms.md
+++ b/.claude/commands/sync-all-platforms.md
@@ -367,16 +367,51 @@ flutter run --flavor horizon
---
-## Documentation Updates
-
-After code changes, update documentation in each repo:
-
-1. **API Reference** - New/changed methods
-2. **Type Definitions** - New/changed types
-3. **Migration Guide** - Breaking changes
-4. **Examples** - Updated usage patterns
-5. **CHANGELOG** - Version history
-6. **llms.txt Files** - AI-friendly documentation
+## Documentation Updates (REQUIRED)
+
+After code changes, update documentation in each platform SDK repo.
+
+### Documentation Checklist Per Platform
+
+For each new feature synced to a platform SDK:
+
+- [ ] **CHANGELOG** - Entry for the new version
+- [ ] **API docs** - Function added to docs/docs/api/ with signature, params, return type
+- [ ] **Type docs** - New types documented with all fields explained
+- [ ] **Example apps** - Working examples demonstrating new features
+- [ ] **Code examples** - Inline code examples in documentation
+- [ ] **Platform notes** - Version requirements (e.g., "iOS 18+", "Billing 8.0+")
+- [ ] **llms.txt** - AI-friendly documentation updated
+
+### Documentation Locations by Platform
+
+| Platform | API Docs | Type Docs | Examples | llms.txt |
+|----------|----------|-----------|----------|----------|
+| expo-iap | `docs/docs/api/` | `docs/docs/types/` | `example/app/` | `docs/static/` |
+| react-native-iap | `docs/docs/api/` | `docs/docs/types/` | `example/src/` | `docs/static/` |
+| kmp-iap | `docs/docs/api/` | `docs/docs/types/` | `example/composeApp/` | `docs/static/` |
+| godot-iap | `docs/` or `README.md` | - | `examples/` | `docs/static/` |
+| flutter_inapp_purchase | `docs/docs/api/` | `docs/docs/types/` | `example/lib/src/screens/` | `docs/static/` |
+| openiap (docs) | `src/pages/docs/apis/` | `src/pages/docs/types/` | `packages/*/Example/` | `public/` |
+
+### Example App Updates (REQUIRED)
+
+Update example apps in each platform SDK to demonstrate new features:
+
+| Platform | Example Location | Key Files |
+|----------|------------------|-----------|
+| expo-iap | `example/app/` | `purchase-flow.tsx`, `subscription-flow.tsx` |
+| react-native-iap | `example/src/screens/` | `PurchaseFlow.tsx`, `SubscriptionFlow.tsx` |
+| kmp-iap | `example/composeApp/` | Compose Multiplatform UI |
+| godot-iap | `examples/` | GDScript scenes |
+| flutter_inapp_purchase | `example/lib/src/screens/` | `purchase_flow_screen.dart` |
+
+**Example Code Guidelines:**
+- Demonstrate ALL new API features with working code
+- Show both success and error handling
+- Include comments explaining the feature
+- Use realistic SKU names and user flows
+- Test on actual devices/simulators before committing
### llms.txt Update Locations
diff --git a/.claude/commands/sync-expo-iap.md b/.claude/commands/sync-expo-iap.md
index 20315bcf..912e9323 100644
--- a/.claude/commands/sync-expo-iap.md
+++ b/.claude/commands/sync-expo-iap.md
@@ -196,7 +196,67 @@ bun run test
cd example && bun run test
```
-### 5. Update Example Code
+### 5. Local OpenIAP Testing (Pre-Deployment)
+
+**IMPORTANT:** expo-iap supports testing local openiap changes before deployment.
+
+#### Enable Local Development
+
+In `example/app.config.ts`:
+
+```typescript
+const LOCAL_OPENIAP_PATHS = {
+ ios: '/Users/crossplatformkorea/Github/hyodotdev/openiap/packages/apple',
+ android: '/Users/crossplatformkorea/Github/hyodotdev/openiap/packages/google',
+} as const;
+
+export default ({config}: ConfigContext): ExpoConfig => {
+ // ...
+ const pluginEntries: NonNullable = [
+ [
+ '../app.plugin.js',
+ {
+ iapkitApiKey: process.env.EXPO_PUBLIC_IAPKIT_API_KEY,
+ enableLocalDev: true, // <-- Enable local openiap
+ localPath: {
+ ios: LOCAL_OPENIAP_PATHS.ios,
+ android: LOCAL_OPENIAP_PATHS.android,
+ },
+ },
+ ],
+ ];
+ // ...
+};
+```
+
+#### Local Dev Workflow
+
+```bash
+# 1. Make changes in openiap monorepo
+cd $OPENIAP_HOME/openiap/packages/apple # or packages/google
+
+# 2. Enable local dev in expo-iap
+cd $IAP_REPOS_HOME/expo-iap/example
+# Edit app.config.ts: set enableLocalDev: true
+
+# 3. Prebuild with local sources
+npx expo prebuild --clean
+
+# 4. Build and test
+npx expo run:ios # iOS with local openiap-apple
+npx expo run:android # Android with local openiap-google
+
+# 5. After testing, disable local dev before committing
+# Edit app.config.ts: set enableLocalDev: false
+```
+
+**When to use local dev:**
+- Testing new openiap features before release
+- Debugging native code issues
+- Verifying type generation changes
+- Testing breaking changes
+
+### 6. Update Example Code (REQUIRED)
**Location:** `example/app/`
@@ -207,7 +267,46 @@ Key example screens:
- `alternative-billing.tsx` - Android alt billing
- `offer-code.tsx` - Promo code redemption
-### 6. Update Tests
+**Example Code Guidelines:**
+- Demonstrate ALL new API features with working code
+- Show both success and error handling
+- Include comments explaining the feature
+- Use realistic SKU names and user flows
+
+**Example for new iOS feature (e.g., Win-Back Offer):**
+```tsx
+// In subscription-flow.tsx
+const handleWinBackOffer = async () => {
+ try {
+ const result = await requestSubscription({
+ sku: 'premium_monthly',
+ winBackOffer: { offerId: 'winback_50_off' } // iOS 18+
+ });
+ console.log('Win-back applied:', result);
+ } catch (error) {
+ console.error('Win-back failed:', error);
+ }
+};
+```
+
+**Example for new Android feature (e.g., Product Status):**
+```tsx
+// In purchase-flow.tsx
+products.forEach((product) => {
+ if (product.productStatusAndroid) {
+ switch (product.productStatusAndroid) {
+ case 'OK': // Show product
+ break;
+ case 'NOT_FOUND': // Show error
+ break;
+ case 'NO_OFFERS_AVAILABLE': // Show ineligible message
+ break;
+ }
+ }
+});
+```
+
+### 7. Update Tests
**Library Tests:** `src/__tests__/`
**Example Tests:** `example/__tests__/`
@@ -220,14 +319,42 @@ bun run test
cd example && bun run test
```
-### 7. Update Documentation
+### 8. Update Documentation (REQUIRED)
**Location:** `docs/`
-- `docs/api/` - API reference
-- `docs/guides/` - Usage guides
-- `docs/examples/` - Code examples
+- `docs/docs/api/` - API reference
+- `docs/docs/types/` - Type definitions
+- `docs/docs/guides/` - Usage guides
+- `docs/docs/examples/` - Code examples
+
+**Documentation Checklist:**
+
+For each new feature synced from openiap:
+
+- [ ] **CHANGELOG.md** - Add entry for new version
+- [ ] **API docs** - Function added with signature, params, return type
+- [ ] **Type docs** - New types documented with all fields explained
+- [ ] **Example code** - Working examples in documentation
+- [ ] **Platform notes** - Version requirements (e.g., "iOS 18+", "Billing 8.0+")
+- [ ] **Migration notes** - Breaking changes documented
+
+**Example Documentation Entry:**
+```mdx
+## requestSubscription
+
+### Win-Back Offers (iOS 18+)
-### 8. Update llms.txt Files
+Win-back offers re-engage churned subscribers:
+
+```typescript
+await requestSubscription({
+ sku: 'premium_monthly',
+ winBackOffer: { offerId: 'winback_50_off' } // iOS 18+
+});
+```
+```
+
+### 9. Update llms.txt Files
**Location:** `docs/static/`
@@ -251,7 +378,7 @@ Update AI-friendly documentation files when APIs or types change:
5. Platform-specific APIs (iOS/Android suffixes)
6. Error handling examples
-### 9. Pre-commit Checklist
+### 10. Pre-commit Checklist
```bash
bun run lint # ESLint
@@ -260,7 +387,18 @@ bun run test # Jest
cd example && bun run test # Example app tests
```
-### 10. Commit and Push
+**Full Sync Checklist:**
+
+- [ ] openiap-versions.json synced
+- [ ] Types regenerated (`bun run generate:types`)
+- [ ] Native code updated (iOS/Android)
+- [ ] Example code demonstrates new features
+- [ ] Tests pass
+- [ ] Documentation updated
+- [ ] llms.txt files updated
+- [ ] Local dev disabled (`enableLocalDev: false`)
+
+### 11. Commit and Push
After completing all sync steps, create a branch and commit the changes:
diff --git a/.claude/commands/sync-flutter-iap.md b/.claude/commands/sync-flutter-iap.md
index 2f224b4e..e62396ac 100644
--- a/.claude/commands/sync-flutter-iap.md
+++ b/.claude/commands/sync-flutter-iap.md
@@ -242,7 +242,7 @@ If error codes change, update `lib/errors.dart`:
- Platform error code mappings
- Exception classes
-### 7. Update Example Code
+### 7. Update Example Code (REQUIRED)
**Location:** `example/lib/src/screens/`
@@ -253,6 +253,52 @@ Key screens:
- `offer_code_screen.dart` - Code redemption
- `builder_demo_screen.dart` - DSL demonstration
+**Example Code Guidelines:**
+- Demonstrate ALL new API features with working code
+- Show both success and error handling
+- Include comments explaining the feature
+- Use realistic SKU names and user flows
+
+**Example for new iOS feature (e.g., Win-Back Offer):**
+```dart
+// In subscription_flow_screen.dart
+Future _handleWinBackOffer() async {
+ try {
+ final result = await FlutterInappPurchase.instance.requestSubscription(
+ RequestSubscriptionParams(
+ sku: 'premium_monthly',
+ winBackOffer: WinBackOfferInputIOS(offerId: 'winback_50_off'), // iOS 18+
+ ),
+ );
+ print('Win-back applied: $result');
+ } catch (e) {
+ print('Win-back failed: $e');
+ }
+}
+```
+
+**Example for new Android feature (e.g., Product Status):**
+```dart
+// In purchase_flow_screen.dart
+for (final product in products) {
+ if (product.productStatusAndroid != null) {
+ switch (product.productStatusAndroid) {
+ case ProductStatusAndroid.ok:
+ // Show product
+ break;
+ case ProductStatusAndroid.notFound:
+ // Show error
+ break;
+ case ProductStatusAndroid.noOffersAvailable:
+ // Show ineligible message
+ break;
+ default:
+ break;
+ }
+ }
+}
+```
+
### 8. Update Tests
**Unit Tests:** `test/`
@@ -266,14 +312,44 @@ flutter test
flutter test --coverage
```
-### 9. Update Documentation
+### 9. Update Documentation (REQUIRED)
**Location:** `docs/`
- Docusaurus site
- `docs/docs/api/` - API reference
+- `docs/docs/types/` - Type definitions
- `docs/docs/guides/` - Usage guides
- `docs/docs/examples/` - Code examples
+**Documentation Checklist:**
+
+For each new feature synced from openiap:
+
+- [ ] **CHANGELOG.md** - Add entry for new version
+- [ ] **API docs** - Function added with signature, params, return type
+- [ ] **Type docs** - New types documented with all fields explained
+- [ ] **Example code** - Working examples in documentation
+- [ ] **Platform notes** - Version requirements (e.g., "iOS 18+", "Billing 8.0+")
+- [ ] **Migration notes** - Breaking changes documented
+
+**Example Documentation Entry:**
+```mdx
+## requestSubscription
+
+### Win-Back Offers (iOS 18+)
+
+Win-back offers re-engage churned subscribers:
+
+```dart
+await FlutterInappPurchase.instance.requestSubscription(
+ RequestSubscriptionParams(
+ sku: 'premium_monthly',
+ winBackOffer: WinBackOfferInputIOS(offerId: 'winback_50_off'),
+ ),
+);
+```
+```
+
### 10. Update llms.txt Files
**Location:** `docs/static/`
@@ -319,6 +395,17 @@ dart format --set-exit-if-changed .
./scripts/pre-commit-checks.sh
```
+**Full Sync Checklist:**
+
+- [ ] openiap-versions.json synced
+- [ ] Types regenerated (`./scripts/generate-type.sh`)
+- [ ] Native code updated (iOS/Android)
+- [ ] Helper/error functions updated if needed
+- [ ] Example code demonstrates new features
+- [ ] Tests pass
+- [ ] Documentation updated
+- [ ] llms.txt files updated
+
### 12. Commit and Push
After completing all sync steps, create a branch and commit the changes:
diff --git a/.claude/commands/sync-godot-iap.md b/.claude/commands/sync-godot-iap.md
index aa4b06a7..5e7d3494 100644
--- a/.claude/commands/sync-godot-iap.md
+++ b/.claude/commands/sync-godot-iap.md
@@ -180,17 +180,82 @@ godot --headless -s addons/gdunit4/test_runner.gd
# Or run from editor: GDUnit4 panel > Run Tests
```
-### 8. Update Example Code
+### 8. Update Example Code (REQUIRED)
**Location:** `examples/`
- Example Godot scenes demonstrating purchase flows
- Sample GDScript code
-### 9. Update Documentation
+**Example Code Guidelines:**
+- Demonstrate ALL new API features with working code
+- Show both success and error handling
+- Include comments explaining the feature
+- Use realistic SKU names and user flows
+
+**Example for new iOS feature (e.g., Win-Back Offer):**
+```gdscript
+# In example scene script
+func _on_winback_button_pressed():
+ var request = RequestSubscriptionIosProps.new()
+ request.sku = "premium_monthly"
+ request.win_back_offer = WinBackOfferInputIOS.new()
+ request.win_back_offer.offer_id = "winback_50_off" # iOS 18+
+
+ var result = await iap.request_subscription_ios(request)
+ print("Win-back applied: ", result)
+```
+
+**Example for new Android feature (e.g., Product Status):**
+```gdscript
+# In example scene script
+for product in products:
+ if product.product_status_android != null:
+ match product.product_status_android:
+ ProductStatusAndroid.OK:
+ # Show product
+ pass
+ ProductStatusAndroid.NOT_FOUND:
+ # Show error
+ pass
+ ProductStatusAndroid.NO_OFFERS_AVAILABLE:
+ # Show ineligible message
+ pass
+```
+
+### 9. Update Documentation (REQUIRED)
**Location:** `docs/` or `README.md`
+**Documentation Checklist:**
+
+For each new feature synced from openiap:
+
+- [ ] **CHANGELOG.md** - Add entry for new version
+- [ ] **API reference** - Function added with signature, params, return type
+- [ ] **Type reference** - New types documented with all fields explained
+- [ ] **Example code** - Working examples in documentation
+- [ ] **Platform notes** - Version requirements (e.g., "iOS 18+", "Billing 8.0+")
+- [ ] **Migration notes** - Breaking changes documented
+
+**Example Documentation Entry:**
+```markdown
+## request_subscription_ios
+
+### Win-Back Offers (iOS 18+)
+
+Win-back offers re-engage churned subscribers:
+
+```gdscript
+var request = RequestSubscriptionIosProps.new()
+request.sku = "premium_monthly"
+request.win_back_offer = WinBackOfferInputIOS.new()
+request.win_back_offer.offer_id = "winback_50_off"
+
+var result = await iap.request_subscription_ios(request)
+```
+```
+
### 10. Update llms.txt Files
**Location:** `docs/static/`
@@ -277,6 +342,17 @@ grep -r "DEPRECATED" addons/
4. Tests passing
5. Documentation updated
+**Full Sync Checklist:**
+
+- [ ] openiap-versions.json synced
+- [ ] Types regenerated and copied (`types.gd`)
+- [ ] GDScript implementation updated (`iap.gd`, `store.gd`)
+- [ ] GDExtension native code updated if needed
+- [ ] Example code demonstrates new features
+- [ ] Tests pass (GDUnit4)
+- [ ] Documentation updated
+- [ ] llms.txt files updated
+
### 11. Commit and Push
After completing all sync steps, create a branch and commit the changes:
diff --git a/.claude/commands/sync-kmp-iap.md b/.claude/commands/sync-kmp-iap.md
index c8b88568..d47500e7 100644
--- a/.claude/commands/sync-kmp-iap.md
+++ b/.claude/commands/sync-kmp-iap.md
@@ -221,12 +221,51 @@ class NewRequestBuilder {
}
```
-### 7. Update Example Code
+### 7. Update Example Code (REQUIRED)
**Location:** `example/composeApp/`
- Compose Multiplatform shared UI
- iOS app: `example/iosApp/`
+**Example Code Guidelines:**
+- Demonstrate ALL new API features with working code
+- Show both success and error handling
+- Include comments explaining the feature
+- Use realistic SKU names and user flows
+
+**Example for new iOS feature (e.g., Win-Back Offer):**
+```kotlin
+// In Example app Compose UI
+Button(onClick = {
+ scope.launch {
+ val result = kmpIapInstance.requestSubscription {
+ ios {
+ sku = "premium_monthly"
+ winBackOffer = WinBackOfferInputIOS(offerId = "winback_50_off") // iOS 18+
+ }
+ }
+ println("Win-back applied: $result")
+ }
+}) {
+ Text("Apply Win-Back Offer")
+}
+```
+
+**Example for new Android feature (e.g., Product Status):**
+```kotlin
+// In Example app Compose UI
+products.forEach { product ->
+ product.productStatusAndroid?.let { status ->
+ when (status) {
+ ProductStatusAndroid.Ok -> { /* Show product */ }
+ ProductStatusAndroid.NotFound -> { /* Show error */ }
+ ProductStatusAndroid.NoOffersAvailable -> { /* Show ineligible message */ }
+ else -> { /* Handle unknown */ }
+ }
+ }
+}
+```
+
### 8. Update Tests
**Location:** `library/src/commonTest/`
@@ -236,13 +275,43 @@ class NewRequestBuilder {
./gradlew :library:build
```
-### 9. Update Documentation
+### 9. Update Documentation (REQUIRED)
**Location:** `docs/`
- `docs/docs/api/` - API documentation
+- `docs/docs/types/` - Type definitions
- `docs/docs/examples/` - Code examples
- `docs/docs/guides/` - Usage guides
+**Documentation Checklist:**
+
+For each new feature synced from openiap:
+
+- [ ] **CHANGELOG.md** - Add entry for new version
+- [ ] **API docs** - Function added with signature, params, return type
+- [ ] **Type docs** - New types documented with all fields explained
+- [ ] **Example code** - Working examples in documentation
+- [ ] **Platform notes** - Version requirements (e.g., "iOS 18+", "Billing 8.0+")
+- [ ] **Migration notes** - Breaking changes documented
+
+**Example Documentation Entry:**
+```mdx
+## requestSubscription
+
+### Win-Back Offers (iOS 18+)
+
+Win-back offers re-engage churned subscribers:
+
+```kotlin
+kmpIapInstance.requestSubscription {
+ ios {
+ sku = "premium_monthly"
+ winBackOffer = WinBackOfferInputIOS(offerId = "winback_50_off")
+ }
+}
+```
+```
+
### 10. Update llms.txt Files
**Location:** `docs/static/`
@@ -276,6 +345,18 @@ Update AI-friendly documentation files when APIs or types change:
./gradlew :library:detekt
```
+**Full Sync Checklist:**
+
+- [ ] openiap-versions.json synced
+- [ ] Types regenerated (`./scripts/generate-types.sh`)
+- [ ] Type aliases updated in `KmpIap.kt`
+- [ ] DSL builders updated if new request types
+- [ ] Native implementations updated (iOS/Android)
+- [ ] Example code demonstrates new features
+- [ ] Tests pass
+- [ ] Documentation updated
+- [ ] llms.txt files updated
+
### 12. Commit and Push
After completing all sync steps, create a branch and commit the changes:
diff --git a/.claude/commands/sync-react-native-iap.md b/.claude/commands/sync-react-native-iap.md
index 47ddbd86..c74a1a3d 100644
--- a/.claude/commands/sync-react-native-iap.md
+++ b/.claude/commands/sync-react-native-iap.md
@@ -203,17 +203,57 @@ yarn android
sed -i '' '/horizonEnabled=true/d' android/gradle.properties
```
-### 6. Update Example Code
+### 6. Update Example Code (REQUIRED)
**React Native Example:** `example/`
Key screens to update:
-
-- `example/src/screens/` - Main app screens
+- `example/src/screens/PurchaseFlow.tsx` - Purchase flow demo
+- `example/src/screens/SubscriptionFlow.tsx` - Subscription demo
+- `example/src/screens/AlternativeBilling.tsx` - Android alt billing
- `example/navigation/` - Navigation setup
**Expo Example:** `example-expo/app/`
+**Example Code Guidelines:**
+- Demonstrate ALL new API features with working code
+- Show both success and error handling
+- Include comments explaining the feature
+- Use realistic SKU names and user flows
+
+**Example for new iOS feature (e.g., Win-Back Offer):**
+```tsx
+// In SubscriptionFlow.tsx
+const handleWinBackOffer = async () => {
+ try {
+ const result = await requestSubscription({
+ sku: 'premium_monthly',
+ winBackOffer: { offerId: 'winback_50_off' } // iOS 18+
+ });
+ console.log('Win-back applied:', result);
+ } catch (error) {
+ console.error('Win-back failed:', error);
+ }
+};
+```
+
+**Example for new Android feature (e.g., Product Status):**
+```tsx
+// In PurchaseFlow.tsx
+products.forEach((product) => {
+ if (product.productStatusAndroid) {
+ switch (product.productStatusAndroid) {
+ case 'OK': // Show product
+ break;
+ case 'NOT_FOUND': // Show error
+ break;
+ case 'NO_OFFERS_AVAILABLE': // Show ineligible message
+ break;
+ }
+ }
+});
+```
+
### 7. Update Tests
**Location:** `src/__tests__/`
@@ -225,12 +265,42 @@ yarn test:ci # CI environment
yarn test:plugin # Expo plugin tests
```
-### 8. Update Documentation
+### 8. Update Documentation (REQUIRED)
**Location:** `docs/`
-- Docusaurus site
+- `docs/docs/api/` - API reference
+- `docs/docs/types/` - Type definitions
+- `docs/docs/guides/` - Usage guides
+- `docs/docs/examples/` - Code examples
- Package manager: Bun
+**Documentation Checklist:**
+
+For each new feature synced from openiap:
+
+- [ ] **CHANGELOG.md** - Add entry for new version
+- [ ] **API docs** - Function added with signature, params, return type
+- [ ] **Type docs** - New types documented with all fields explained
+- [ ] **Example code** - Working examples in documentation
+- [ ] **Platform notes** - Version requirements (e.g., "iOS 18+", "Billing 8.0+")
+- [ ] **Migration notes** - Breaking changes documented
+
+**Example Documentation Entry:**
+```mdx
+## requestSubscription
+
+### Win-Back Offers (iOS 18+)
+
+Win-back offers re-engage churned subscribers:
+
+```typescript
+await requestSubscription({
+ sku: 'premium_monthly',
+ winBackOffer: { offerId: 'winback_50_off' }
+});
+```
+```
+
### 9. Update llms.txt Files
**Location:** `docs/static/`
@@ -266,6 +336,17 @@ yarn lint:prettier # Prettier
yarn test # Tests
```
+**Full Sync Checklist:**
+
+- [ ] openiap-versions.json synced
+- [ ] Types regenerated (`yarn generate:types`)
+- [ ] Nitro specs regenerated if needed (`yarn specs`)
+- [ ] Native code updated (iOS/Android)
+- [ ] Example code demonstrates new features
+- [ ] Tests pass
+- [ ] Documentation updated
+- [ ] llms.txt files updated
+
### 11. Commit and Push
After completing all sync steps, create a branch and commit the changes:
From 14dab65a1774d31c273633742fcf8ca178123c50 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sun, 18 Jan 2026 22:34:44 +0900
Subject: [PATCH 08/11] fix: address PR #66 review comments
- Fix markdown lint issues (MD031/MD040) in audit-code.md
- Fix markdown fenced-block spacing in sync-expo-iap.md, sync-godot-iap.md
- Replace hard-coded paths with placeholders in sync-expo-iap.md
- Update Google Play Billing version history (correct 8.0, 8.1, 8.3 descriptions)
- Fix originalPlatform back-deployment claim in context.md
- Add guard for winBackOffer product context in StoreKitTypesBridge.swift
- Use #if swift(>=6.1) for JWS promo offers and intro eligibility override
- Fix ProductStatusAndroid enum casing (Ok, NotFound, etc.) in notes.tsx
- Add missing Unknown and null cases to when expression
Co-Authored-By: Claude Opus 4.5
---
.claude/commands/audit-code.md | 9 ++-
.claude/commands/sync-expo-iap.md | 11 ++--
.claude/commands/sync-godot-iap.md | 7 ++-
knowledge/_claude-context/context.md | 8 +--
.../Sources/Helpers/StoreKitTypesBridge.swift | 56 ++++++++++++-------
.../docs/src/pages/docs/updates/notes.tsx | 4 +-
6 files changed, 63 insertions(+), 32 deletions(-)
diff --git a/.claude/commands/audit-code.md b/.claude/commands/audit-code.md
index f181c801..e6f6ad9c 100644
--- a/.claude/commands/audit-code.md
+++ b/.claude/commands/audit-code.md
@@ -171,6 +171,7 @@ Update the documentation site for users:
- Add entry at the TOP of `allNotes` array (newest first)
Example notes.tsx entry:
+
```typescript
// Add to TOP of allNotes array in notes.tsx
{
@@ -249,6 +250,7 @@ Update example apps to demonstrate new features:
- Test on actual devices before committing
**Example for Win-Back Offer (iOS):**
+
```swift
// In SubscriptionFlowScreen.swift
Button("Apply Win-Back Offer") {
@@ -263,6 +265,7 @@ Button("Apply Win-Back Offer") {
```
**Example for Product Status (Android):**
+
```kotlin
// In AllProductsScreen.kt
product.productStatusAndroid?.let { status ->
@@ -291,6 +294,7 @@ For each new feature implemented:
#### 7e. Documentation Examples
**New Function (e.g., win-back offer):**
+
```mdx
## requestSubscription
@@ -305,15 +309,16 @@ For each new feature implemented:
Win-back offers re-engage churned subscribers:
-```typescript
+~~~typescript
await requestSubscription({
sku: 'premium_monthly',
winBackOffer: { offerId: 'winback_50_off' }
});
-```
+~~~
```
**New Type:**
+
```mdx
## ProductStatusAndroid
diff --git a/.claude/commands/sync-expo-iap.md b/.claude/commands/sync-expo-iap.md
index 912e9323..4d42a1d3 100644
--- a/.claude/commands/sync-expo-iap.md
+++ b/.claude/commands/sync-expo-iap.md
@@ -206,8 +206,8 @@ In `example/app.config.ts`:
```typescript
const LOCAL_OPENIAP_PATHS = {
- ios: '/Users/crossplatformkorea/Github/hyodotdev/openiap/packages/apple',
- android: '/Users/crossplatformkorea/Github/hyodotdev/openiap/packages/google',
+ ios: '/packages/apple',
+ android: '/packages/google',
} as const;
export default ({config}: ConfigContext): ExpoConfig => {
@@ -274,6 +274,7 @@ Key example screens:
- Use realistic SKU names and user flows
**Example for new iOS feature (e.g., Win-Back Offer):**
+
```tsx
// In subscription-flow.tsx
const handleWinBackOffer = async () => {
@@ -290,6 +291,7 @@ const handleWinBackOffer = async () => {
```
**Example for new Android feature (e.g., Product Status):**
+
```tsx
// In purchase-flow.tsx
products.forEach((product) => {
@@ -339,6 +341,7 @@ For each new feature synced from openiap:
- [ ] **Migration notes** - Breaking changes documented
**Example Documentation Entry:**
+
```mdx
## requestSubscription
@@ -346,12 +349,12 @@ For each new feature synced from openiap:
Win-back offers re-engage churned subscribers:
-```typescript
+~~~typescript
await requestSubscription({
sku: 'premium_monthly',
winBackOffer: { offerId: 'winback_50_off' } // iOS 18+
});
-```
+~~~
```
### 9. Update llms.txt Files
diff --git a/.claude/commands/sync-godot-iap.md b/.claude/commands/sync-godot-iap.md
index 5e7d3494..095883f0 100644
--- a/.claude/commands/sync-godot-iap.md
+++ b/.claude/commands/sync-godot-iap.md
@@ -194,6 +194,7 @@ godot --headless -s addons/gdunit4/test_runner.gd
- Use realistic SKU names and user flows
**Example for new iOS feature (e.g., Win-Back Offer):**
+
```gdscript
# In example scene script
func _on_winback_button_pressed():
@@ -207,6 +208,7 @@ func _on_winback_button_pressed():
```
**Example for new Android feature (e.g., Product Status):**
+
```gdscript
# In example scene script
for product in products:
@@ -239,6 +241,7 @@ For each new feature synced from openiap:
- [ ] **Migration notes** - Breaking changes documented
**Example Documentation Entry:**
+
```markdown
## request_subscription_ios
@@ -246,14 +249,14 @@ For each new feature synced from openiap:
Win-back offers re-engage churned subscribers:
-```gdscript
+~~~gdscript
var request = RequestSubscriptionIosProps.new()
request.sku = "premium_monthly"
request.win_back_offer = WinBackOfferInputIOS.new()
request.win_back_offer.offer_id = "winback_50_off"
var result = await iap.request_subscription_ios(request)
-```
+~~~
```
### 10. Update llms.txt Files
diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md
index 4b9eeb96..8a3a616b 100644
--- a/knowledge/_claude-context/context.md
+++ b/knowledge/_claude-context/context.md
@@ -1494,11 +1494,11 @@ Google Play Billing Library enables in-app purchases and subscriptions on Androi
| Version | Release Date | Key Features |
|---------|--------------|--------------|
-| 8.0 | 2025-06-30 | Auto-reconnect, product-level status codes, one-time products with multiple offers, sub-response codes |
-| 8.1 | 2025-11-06 | Suspended subscriptions (`isSuspended`), `includeSuspended` parameter, pre-order details, product-level subscription replacement, `KEEP_EXISTING` mode |
+| 8.0 | 2025-06-30 | One-time product improvements, multiple purchase options/offers for one-time products, product-level status for unfetched products |
+| 8.1 | 2025-11-06 | Minor release with bug fixes and improvements |
| 8.2 | 2025-12-09 | Billing Programs API (external content links, external offers), deprecates old External Offers API |
| 8.2.1 | 2025-12-15 | Bug fix for `isBillingProgramAvailableAsync()` and `createBillingProgramReportingDetailsAsync()` |
-| 8.3 | 2025-12-23 | External Payments program (Japan only), developer billing options |
+| 8.3 | 2025-12-23 | External Payments program, developer billing options |
**Current Version**: 8.3.0 (as of January 2026)
@@ -2571,7 +2571,7 @@ This document provides external API reference for Apple's StoreKit 2 framework.
| UI context for purchases | iOS 18.2 | Required for proper payment sheet display |
| External purchase notice | iOS 18.2 | `presentExternalPurchaseNoticeSheetIOS` |
| `appTransactionID` | iOS 18.4 | Globally unique app transaction identifier (back-deployed to iOS 15) |
-| `originalPlatform` | iOS 18.4 | Original purchase platform (back-deployed to iOS 15) |
+| `originalPlatform` | iOS 18.4 | Original purchase platform |
| `Offer.Period` | iOS 18.4 | Offer period information |
| `advancedCommerceInfo` | iOS 18.4 | Advanced Commerce API data |
| Expanded offer codes | iOS 18.4 | For consumables/non-consumables |
diff --git a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift
index e4109666..b80802a0 100644
--- a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift
+++ b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift
@@ -380,7 +380,15 @@ enum StoreKitTypesBridge {
// Win-back offers (iOS 18+)
// Used to re-engage churned subscribers
if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) {
- if let winBackInput = props.winBackOffer, let product = product {
+ if let winBackInput = props.winBackOffer {
+ guard let product = product else {
+ OpenIapLog.error("❌ Win-back offer requires product context")
+ throw PurchaseError.make(
+ code: .developerError,
+ productId: props.sku,
+ message: "Win-back offer requires product context. Fetch the product before calling requestPurchase."
+ )
+ }
// Find the win-back offer from the product's promotional offers
if let subscription = product.subscription {
let winBackOffer = subscription.promotionalOffers.first { offer in
@@ -409,30 +417,40 @@ enum StoreKitTypesBridge {
}
// JWS Promotional Offer (iOS 15+, WWDC 2025)
// New signature format using compact JWS string for promotional offers
- // Back-deployed to iOS 15
+ // Back-deployed to iOS 15, but requires Xcode 16.4+ / Swift 6.1+ to compile
if let jwsOffer = props.promotionalOfferJWS {
- // Note: This uses the new promotionalOffer(_:) purchase option that accepts JWS
- // The API was announced at WWDC 2025 and back-deployed to iOS 15
- // We use the legacy promotional offer API as fallback since the new API
- // requires Xcode 16.4+ / Swift 6.1+ to compile
- OpenIapLog.debug("⚠️ JWS promotional offer provided: \(jwsOffer.offerId)")
- // TODO: When Xcode 16.4+ is available, use:
- // options.insert(.promotionalOffer(jwsOffer.jws))
- // For now, log a warning - developers should use withOffer for promotional offers
- OpenIapLog.debug("⚠️ JWS promotional offers require Xcode 16.4+. Use withOffer with signature-based promotional offers instead.")
+ #if swift(>=6.1)
+ // Swift 6.1+ implementation
+ options.insert(.promotionalOffer(jwsOffer.jws))
+ OpenIapLog.debug("✅ Added JWS promotional offer: \(jwsOffer.offerId)")
+ #else
+ // Swift < 6.1: API not available, throw error to fail fast
+ OpenIapLog.error("❌ JWS promotional offers require Xcode 16.4+ / Swift 6.1+")
+ throw PurchaseError.make(
+ code: .developerError,
+ productId: props.sku,
+ message: "JWS promotional offers require Xcode 16.4+ / Swift 6.1+. Use withOffer with signature-based promotional offers instead."
+ )
+ #endif
}
// Introductory Offer Eligibility Override (iOS 15+, WWDC 2025)
// Allows overriding the system's eligibility check for introductory offers
- // Back-deployed to iOS 15
+ // Back-deployed to iOS 15, but requires Xcode 16.4+ / Swift 6.1+ to compile
if let eligibility = props.introductoryOfferEligibility {
- // Note: This uses the new introductoryOfferEligibility(_:) purchase option
- // The API was announced at WWDC 2025 and back-deployed to iOS 15
- // We need Xcode 16.4+ / Swift 6.1+ to compile this
- OpenIapLog.debug("⚠️ Introductory offer eligibility override requested: \(eligibility)")
- // TODO: When Xcode 16.4+ is available, use:
- // options.insert(.introductoryOfferEligibility(eligibility))
- OpenIapLog.debug("⚠️ Introductory offer eligibility override requires Xcode 16.4+. The system will determine eligibility automatically.")
+ #if swift(>=6.1)
+ // Swift 6.1+ implementation
+ options.insert(.introductoryOfferEligibility(eligibility))
+ OpenIapLog.debug("✅ Added introductory offer eligibility override: \(eligibility)")
+ #else
+ // Swift < 6.1: API not available, throw error to fail fast
+ OpenIapLog.error("❌ Introductory offer eligibility override requires Xcode 16.4+ / Swift 6.1+")
+ throw PurchaseError.make(
+ code: .developerError,
+ productId: props.sku,
+ message: "Introductory offer eligibility override requires Xcode 16.4+ / Swift 6.1+. The system will determine eligibility automatically."
+ )
+ #endif
}
// Advanced Commerce Data (iOS 15+)
diff --git a/packages/docs/src/pages/docs/updates/notes.tsx b/packages/docs/src/pages/docs/updates/notes.tsx
index 79c16b80..0f315d6a 100644
--- a/packages/docs/src/pages/docs/updates/notes.tsx
+++ b/packages/docs/src/pages/docs/updates/notes.tsx
@@ -81,7 +81,7 @@ requestSubscription({
Product-level status codes indicating why products couldn't be fetched.
- ProductStatusAndroid - New enum with values: OK, NOT_FOUND, NO_OFFERS_AVAILABLE, UNKNOWN
+ ProductStatusAndroid - New enum with values: Ok, NotFound, NoOffersAvailable, Unknown
productStatusAndroid - New field on ProductAndroid and ProductSubscriptionAndroid
@@ -91,6 +91,8 @@ when (product?.productStatusAndroid) {
ProductStatusAndroid.Ok -> { /* Success */ }
ProductStatusAndroid.NotFound -> { /* SKU doesn't exist */ }
ProductStatusAndroid.NoOffersAvailable -> { /* User not eligible */ }
+ ProductStatusAndroid.Unknown -> { /* Unknown status */ }
+ null -> { /* No product or status */ }
}`}
From 4f2e67aac396a8480624c79ed6ecc7057f36931c Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sun, 18 Jan 2026 22:42:01 +0900
Subject: [PATCH 09/11] chore(skills): enhance review-pr with reply before
resolve
- Add reply with commit link before resolving fixed threads
- Add reply templates for different fix scenarios
- Update decision tree with reply requirements
- Add Thread Resolution Rules table
- Clarify that invalid reviews get replies but NOT resolved
- Add important notes about never silent resolving
Co-Authored-By: Claude Opus 4.5
---
.claude/commands/review-pr.md | 82 ++++++++++++++++++++++++++++-------
1 file changed, 66 insertions(+), 16 deletions(-)
diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md
index d5d828a3..54a2300c 100644
--- a/.claude/commands/review-pr.md
+++ b/.claude/commands/review-pr.md
@@ -15,13 +15,15 @@ Automated workflow to review, fix, and respond to PR review comments.
↓
3. For each comment:
├─ Valid → Fix code
- └─ Invalid → Add reply comment explaining why
+ └─ Invalid → Add reply comment explaining why (don't resolve)
↓
4. Run lint, typecheck, tests (BEFORE commit)
↓
5. If all pass → Commit and push
↓
-6. Resolve fixed threads
+6. For each fixed thread:
+ ├─ Reply with commit link + what changed
+ └─ Resolve thread
```
## Steps
@@ -172,7 +174,23 @@ git push
### 8. Resolve Fixed Threads
-For each thread that was fixed:
+For each thread that was fixed, **add a reply comment** explaining what was fixed and linking to the commit, then resolve:
+
+**Step 1: Add reply comment with fix details**
+
+```bash
+gh api graphql -f query='
+mutation {
+ addPullRequestReviewThreadReply(input: {
+ pullRequestReviewThreadId: "THREAD_ID",
+ body: "Fixed in COMMIT_HASH.\n\n**What was changed:**\n- DESCRIPTION_OF_FIX\n\nThanks for catching this!"
+ }) {
+ comment { id }
+ }
+}'
+```
+
+**Step 2: Resolve the thread**
```bash
gh api graphql -f query='
@@ -183,6 +201,20 @@ mutation {
}'
```
+**Reply templates for fixed threads:**
+
+- **Simple fix:**
+ > "Fixed in `abc1234`. Added blank lines around fenced code blocks."
+
+- **Code change:**
+ > "Fixed in `abc1234`.\n\n**Changes:**\n- Added guard clause for null check\n- Throws explicit error instead of silent ignore\n\nThanks for the thorough review!"
+
+- **Documentation fix:**
+ > "Fixed in `abc1234`. Updated version history to match official release notes."
+
+- **Multiple fixes in one commit:**
+ > "Fixed in `abc1234` along with other review items.\n\n**This thread:** Replaced hard-coded paths with placeholders."
+
## Decision Tree
```text
@@ -192,20 +224,35 @@ Review Comment
│ │
│ ├─► YES: Can we fix it?
│ │ │
- │ │ ├─► YES → Fix code, resolve thread
- │ │ └─► NO (out of scope) → Reply, don't resolve
+ │ │ ├─► YES → Fix code, reply with commit link, resolve thread
+ │ │ └─► NO (out of scope) → Reply explaining why, don't resolve
│ │
│ └─► NO: Why is it invalid?
│ │
- │ ├─► Wrong suggestion → Reply with correction
- │ ├─► Misunderstanding → Reply with clarification
- │ └─► Style preference → Reply citing conventions
+ │ ├─► Wrong suggestion → Reply with correction, don't resolve
+ │ ├─► Misunderstanding → Reply with clarification, don't resolve
+ │ └─► Style preference → Reply citing conventions, don't resolve
│
└─► Is it already fixed?
│
- └─► YES → Resolve thread
+ └─► YES → Reply with commit link, resolve thread
```
+## Thread Resolution Rules
+
+| Scenario | Reply? | Resolve? | Content |
+|----------|--------|----------|---------|
+| Fixed the issue | ✅ YES | ✅ YES | Commit link + what changed |
+| Already fixed in previous commit | ✅ YES | ✅ YES | Commit link |
+| Disagree with suggestion | ✅ YES | ❌ NO | Explanation + reasoning |
+| Out of scope | ✅ YES | ❌ NO | Why it's out of scope |
+| Misunderstanding | ✅ YES | ❌ NO | Clarification |
+| Need more info from reviewer | ✅ YES | ❌ NO | Question for clarification |
+
+**Important:** Never resolve a thread without either:
+1. Fixing the issue (with commit link in reply)
+2. Getting agreement from the reviewer that it's not needed
+
## Example Usage
```bash
@@ -227,16 +274,16 @@ After running, provide a summary:
**Threads processed:** 12
### Fixed (8)
-- ✅ `scripts/agent/README.md:7` - Added language tag to code block
-- ✅ `scripts/agent/agent-coder.ts:56` - Fixed path resolution
+- ✅ `scripts/agent/README.md:7` - Added language tag to code block → Replied with `abc1234`
+- ✅ `scripts/agent/agent-coder.ts:56` - Fixed path resolution → Replied with `abc1234`
- ...
-### Replied (2)
-- 💬 `packages/gql/schema.graphql:42` - Disagreed: follows project convention
-- 💬 `packages/apple/Sources/OpenIap.swift:15` - Out of scope for this PR
+### Replied Only (2) - Not Resolved
+- 💬 `packages/gql/schema.graphql:42` - Disagreed: follows project convention (waiting for reviewer response)
+- 💬 `packages/apple/Sources/OpenIap.swift:15` - Out of scope for this PR (waiting for reviewer response)
-### Already Resolved (2)
-- ⏭️ `CLAUDE.md:85` - Was fixed in previous commit
+### Already Fixed (2)
+- ⏭️ `CLAUDE.md:85` - Was fixed in previous commit `def5678` → Replied and resolved
### Commits
- `abc1234` - fix: address PR review comments (8 files)
@@ -255,3 +302,6 @@ After running, provide a summary:
5. **ALWAYS run lint/tsc/tests BEFORE commit** - Never commit if any check fails
6. **Group commits** - Batch related fixes into logical commits
7. **Fix test failures** - If tests fail after your fix, fix the issue before committing
+8. **Always reply before resolving** - When fixing an issue, reply with the commit hash and what changed before resolving the thread
+9. **Never silent resolve** - Reviewers should be able to see what action was taken on their comment
+10. **Link commits** - Use short commit hash (7 chars) with backticks: \`abc1234\`
From ecddf66a037be86d97c667a418a3bec5d07a1088 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sun, 18 Jan 2026 22:44:29 +0900
Subject: [PATCH 10/11] chore(skills): simplify project review-pr to extend
global command
Project-specific review-pr.md now only contains:
- Project-specific build commands table
- Project conventions reference
- Links to CLAUDE.md and knowledge/internal/
Global command at ~/.claude/commands/review-pr.md handles:
- Full workflow documentation
- GraphQL API calls
- Decision tree
- Thread resolution rules
- Reply templates
Co-Authored-By: Claude Opus 4.5
---
.claude/commands/review-pr.md | 313 +++-------------------------------
1 file changed, 19 insertions(+), 294 deletions(-)
diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md
index 54a2300c..b97071c8 100644
--- a/.claude/commands/review-pr.md
+++ b/.claude/commands/review-pr.md
@@ -1,307 +1,32 @@
# Review PR Comments
-Automated workflow to review, fix, and respond to PR review comments.
+Review and address PR review comments for this repository.
+
+> **Note:** This extends the global `/review-pr` command with project-specific checks.
## Arguments
- `$ARGUMENTS` - PR number (e.g., `65`) or PR URL
-## Workflow
-
-```text
-1. Fetch PR review threads
- ↓
-2. Analyze each unresolved comment
- ↓
-3. For each comment:
- ├─ Valid → Fix code
- └─ Invalid → Add reply comment explaining why (don't resolve)
- ↓
-4. Run lint, typecheck, tests (BEFORE commit)
- ↓
-5. If all pass → Commit and push
- ↓
-6. For each fixed thread:
- ├─ Reply with commit link + what changed
- └─ Resolve thread
-```
-
-## Steps
-
-### 1. Parse PR Number
-
-Extract PR number from argument:
-- If URL: `https://github.com/hyodotdev/openiap/pull/65` → `65`
-- If number: `65` → `65`
-
-### 2. Fetch Unresolved Review Threads
-
-```bash
-gh api graphql -f query='
-query {
- repository(owner: "hyodotdev", name: "openiap") {
- pullRequest(number: PR_NUMBER) {
- reviewThreads(first: 50) {
- nodes {
- id
- isResolved
- path
- line
- comments(first: 10) {
- nodes {
- id
- body
- author { login }
- }
- }
- }
- }
- }
- }
-}'
-```
-
-### 3. Analyze Each Review Comment
-
-For each unresolved thread, determine:
-
-**A. Is the review comment valid?**
-- Does it point to a real issue in the code?
-- Is the suggested fix correct?
-- Does it align with project conventions (check CLAUDE.md, CONVENTION.md)?
-
-**B. Classification:**
-
-| Type | Action |
-|------|--------|
-| Valid bug/issue | Fix the code |
-| Valid improvement | Fix the code |
-| Valid style issue | Fix the code |
-| Incorrect suggestion | Reply with explanation |
-| Misunderstanding | Reply with clarification |
-| Already fixed | Resolve thread |
-| Out of scope | Reply explaining scope |
-
-### 4. Handle Valid Reviews
-
-For valid review comments:
-
-1. **Read the file** mentioned in the review
-2. **Understand the issue** from the comment
-3. **Fix the code** following project conventions
-4. **Verify** the fix doesn't break anything
-5. **Mark for commit** (collect all fixes)
-
-### 5. Handle Invalid Reviews
-
-For invalid/incorrect review comments, add a reply:
-
-```bash
-gh api graphql -f query='
-mutation {
- addPullRequestReviewComment(input: {
- pullRequestReviewThreadId: "THREAD_ID",
- body: "YOUR_REPLY_MESSAGE"
- }) {
- comment { id }
- }
-}'
-```
-
-**Reply templates:**
-
-- **Incorrect suggestion:**
- > "This suggestion would actually cause [issue]. The current implementation is correct because [reason]."
-
-- **Misunderstanding:**
- > "I think there may be a misunderstanding here. [Clarification of how the code works]."
-
-- **Already fixed:**
- > "This has been addressed in commit [hash]."
-
-- **Out of scope:**
- > "This is outside the scope of this PR. Created issue #XX to track this separately."
-
-- **Disagree with style:**
- > "This follows the project convention defined in [CLAUDE.md/CONVENTION.md]. [Quote relevant section]."
-
-### 6. Run Lint, Typecheck, Tests (BEFORE Commit)
-
-**CRITICAL**: Always verify fixes don't break anything BEFORE committing:
-
-```bash
-# Based on changed files, run relevant checks:
-
-# scripts/agent changes
-cd scripts/agent && bun test
-
-# packages/gql changes
-cd packages/gql && bun run lint && bun run typecheck
-
-# packages/docs changes
-cd packages/docs && bun run lint && bun run typecheck
-
-# packages/apple changes
-cd packages/apple && swift build && swift test
-
-# packages/google changes (test BOTH flavors)
-cd packages/google && ./gradlew :openiap:compilePlayDebugKotlin && ./gradlew :openiap:compileHorizonDebugKotlin
-```
-
-**If any check fails:**
-1. Fix the issue
-2. Re-run the failing check
-3. Only proceed to commit when ALL checks pass
-
-### 7. Commit and Push Fixes
-
-After ALL checks pass, commit the changes:
-
-```bash
-# Stage all changes
-git add -A
-
-# Commit with descriptive message
-git commit -m "fix: address PR review comments
-
-- [List each fix made]
-
-Co-Authored-By: Claude Opus 4.5 "
-
-# Push to remote
-git push
-```
-
-### 8. Resolve Fixed Threads
-
-For each thread that was fixed, **add a reply comment** explaining what was fixed and linking to the commit, then resolve:
-
-**Step 1: Add reply comment with fix details**
-
-```bash
-gh api graphql -f query='
-mutation {
- addPullRequestReviewThreadReply(input: {
- pullRequestReviewThreadId: "THREAD_ID",
- body: "Fixed in COMMIT_HASH.\n\n**What was changed:**\n- DESCRIPTION_OF_FIX\n\nThanks for catching this!"
- }) {
- comment { id }
- }
-}'
-```
-
-**Step 2: Resolve the thread**
-
-```bash
-gh api graphql -f query='
-mutation {
- resolveReviewThread(input: {threadId: "THREAD_ID"}) {
- thread { id }
- }
-}'
-```
-
-**Reply templates for fixed threads:**
-
-- **Simple fix:**
- > "Fixed in `abc1234`. Added blank lines around fenced code blocks."
-
-- **Code change:**
- > "Fixed in `abc1234`.\n\n**Changes:**\n- Added guard clause for null check\n- Throws explicit error instead of silent ignore\n\nThanks for the thorough review!"
-
-- **Documentation fix:**
- > "Fixed in `abc1234`. Updated version history to match official release notes."
-
-- **Multiple fixes in one commit:**
- > "Fixed in `abc1234` along with other review items.\n\n**This thread:** Replaced hard-coded paths with placeholders."
-
-## Decision Tree
-
-```text
-Review Comment
- │
- ├─► Is it a valid issue?
- │ │
- │ ├─► YES: Can we fix it?
- │ │ │
- │ │ ├─► YES → Fix code, reply with commit link, resolve thread
- │ │ └─► NO (out of scope) → Reply explaining why, don't resolve
- │ │
- │ └─► NO: Why is it invalid?
- │ │
- │ ├─► Wrong suggestion → Reply with correction, don't resolve
- │ ├─► Misunderstanding → Reply with clarification, don't resolve
- │ └─► Style preference → Reply citing conventions, don't resolve
- │
- └─► Is it already fixed?
- │
- └─► YES → Reply with commit link, resolve thread
-```
-
-## Thread Resolution Rules
-
-| Scenario | Reply? | Resolve? | Content |
-|----------|--------|----------|---------|
-| Fixed the issue | ✅ YES | ✅ YES | Commit link + what changed |
-| Already fixed in previous commit | ✅ YES | ✅ YES | Commit link |
-| Disagree with suggestion | ✅ YES | ❌ NO | Explanation + reasoning |
-| Out of scope | ✅ YES | ❌ NO | Why it's out of scope |
-| Misunderstanding | ✅ YES | ❌ NO | Clarification |
-| Need more info from reviewer | ✅ YES | ❌ NO | Question for clarification |
-
-**Important:** Never resolve a thread without either:
-1. Fixing the issue (with commit link in reply)
-2. Getting agreement from the reviewer that it's not needed
-
-## Example Usage
-
-```bash
-# By PR number
-/review-pr 65
-
-# By PR URL
-/review-pr https://github.com/hyodotdev/openiap/pull/65
-```
-
-## Output Summary
-
-After running, provide a summary:
-
-```markdown
-## PR Review Summary
-
-**PR:** #65
-**Threads processed:** 12
-
-### Fixed (8)
-- ✅ `scripts/agent/README.md:7` - Added language tag to code block → Replied with `abc1234`
-- ✅ `scripts/agent/agent-coder.ts:56` - Fixed path resolution → Replied with `abc1234`
-- ...
+## Project-Specific Build Commands
-### Replied Only (2) - Not Resolved
-- 💬 `packages/gql/schema.graphql:42` - Disagreed: follows project convention (waiting for reviewer response)
-- 💬 `packages/apple/Sources/OpenIap.swift:15` - Out of scope for this PR (waiting for reviewer response)
+Based on changed files, run these checks BEFORE committing:
-### Already Fixed (2)
-- ⏭️ `CLAUDE.md:85` - Was fixed in previous commit `def5678` → Replied and resolved
+| Package | Commands |
+|---------|----------|
+| `scripts/agent/` | `cd scripts/agent && bun test` |
+| `packages/gql/` | `cd packages/gql && bun run lint && bun run typecheck` |
+| `packages/docs/` | `cd packages/docs && bun run lint && bun run typecheck` |
+| `packages/apple/` | `cd packages/apple && swift build` |
+| `packages/google/` | `./gradlew :openiap:compilePlayDebugKotlin && ./gradlew :openiap:compileHorizonDebugKotlin` |
-### Commits
-- `abc1234` - fix: address PR review comments (8 files)
+**Important:** For Android, test BOTH Play and Horizon flavors.
-### Tests
-- ✅ 48 tests passing
-- ✅ TypeScript typecheck passed
-```
+## Project Conventions
-## Important Notes
+When reviewing, check these project-specific rules:
+- **iOS functions**: Must end with `IOS` suffix (e.g., `syncIOS`)
+- **Android functions in packages/google**: NO `Android` suffix (it's Android-only)
+- **Generated files**: Do NOT edit `packages/apple/Sources/Models/Types.swift` or `packages/google/openiap/src/main/Types.kt`
-1. **Always read before fixing** - Never suggest fixes without reading the actual code
-2. **Check conventions** - Reference CLAUDE.md and package CONVENTION.md files
-3. **Be respectful** - When disagreeing, explain clearly and cite sources
-4. **Don't over-fix** - Only fix what the review asks for, don't add extra changes
-5. **ALWAYS run lint/tsc/tests BEFORE commit** - Never commit if any check fails
-6. **Group commits** - Batch related fixes into logical commits
-7. **Fix test failures** - If tests fail after your fix, fix the issue before committing
-8. **Always reply before resolving** - When fixing an issue, reply with the commit hash and what changed before resolving the thread
-9. **Never silent resolve** - Reviewers should be able to see what action was taken on their comment
-10. **Link commits** - Use short commit hash (7 chars) with backticks: \`abc1234\`
+See [CLAUDE.md](../../CLAUDE.md) and [knowledge/internal/](../../knowledge/internal/) for full conventions.
From ad840a81da335ab33abd04f26c33fa3599c41c37 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sun, 18 Jan 2026 22:45:56 +0900
Subject: [PATCH 11/11] fix: address PR #66 review comments (round 2)
- Rename "Version History" to "Google Play Billing Version History" (MD024)
- Clarify appTransactionID vs originalPlatform back-deployment in context.md
- Add else branch to fail fast when winBackOffer used on unsupported OS
Co-Authored-By: Claude Opus 4.5
---
knowledge/_claude-context/context.md | 6 ++++--
packages/apple/Sources/Helpers/StoreKitTypesBridge.swift | 8 ++++++++
2 files changed, 12 insertions(+), 2 deletions(-)
diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md
index 8a3a616b..bef00f74 100644
--- a/knowledge/_claude-context/context.md
+++ b/knowledge/_claude-context/context.md
@@ -1490,7 +1490,7 @@ await endConnection();
Google Play Billing Library enables in-app purchases and subscriptions on Android devices.
-## Version History
+## Google Play Billing Version History
| Version | Release Date | Key Features |
|---------|--------------|--------------|
@@ -2762,8 +2762,10 @@ let result = try await product.purchase(confirmIn: window)
```swift
let appTransaction = try await AppTransaction.shared
-// New in iOS 18.4 (back-deployed to iOS 15)
+// appTransactionID: New in iOS 18.4 (back-deployed to iOS 15)
let appTransactionID = appTransaction.appTransactionID // Globally unique per Apple Account
+
+// originalPlatform: New in iOS 18.4 (iOS 18.4+ only, NOT back-deployed)
let originalPlatform = appTransaction.originalPlatform // Original purchase platform
```
diff --git a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift
index b80802a0..90af35e6 100644
--- a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift
+++ b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift
@@ -414,6 +414,14 @@ enum StoreKitTypesBridge {
)
}
}
+ } else if props.winBackOffer != nil {
+ // Fail fast when win-back offers are used on unsupported OS versions
+ OpenIapLog.error("❌ Win-back offers require iOS 18+ / macOS 15+ / tvOS 18+ / watchOS 11+ / visionOS 2+")
+ throw PurchaseError.make(
+ code: .developerError,
+ productId: props.sku,
+ message: "Win-back offers are only supported on iOS 18+ / macOS 15+ / tvOS 18+ / watchOS 11+ / visionOS 2+."
+ )
}
// JWS Promotional Offer (iOS 15+, WWDC 2025)
// New signature format using compact JWS string for promotional offers