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