diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 924839d0..1d17f520 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -324,6 +324,26 @@ public struct AppTransaction: Codable { public var signedDate: Double } +/// Discount amount details for one-time purchase offers (Android) +/// Available in Google Play Billing Library 7.0+ +public struct DiscountAmountAndroid: Codable { + /// Discount amount in micro-units (1,000,000 = 1 unit of currency) + public var discountAmountMicros: String + /// Formatted discount amount with currency sign (e.g., "$4.99") + public var formattedDiscountAmount: String +} + +/// Discount display information for one-time purchase offers (Android) +/// Available in Google Play Billing Library 7.0+ +public struct DiscountDisplayInfoAndroid: Codable { + /// Absolute discount amount details + /// Only returned for fixed amount discounts + public var discountAmount: DiscountAmountAndroid? + /// Percentage discount (e.g., 33 for 33% off) + /// Only returned for percentage-based discounts + public var percentageDiscount: Int? +} + public struct DiscountIOS: Codable { public var identifier: String public var localizedPrice: String? @@ -376,6 +396,15 @@ public enum FetchProductsResult { case subscriptions([ProductSubscription]?) } +/// Limited quantity information for one-time purchase offers (Android) +/// Available in Google Play Billing Library 7.0+ +public struct LimitedQuantityInfoAndroid: Codable { + /// Maximum quantity a user can purchase + public var maximumQuantity: Int + /// Remaining quantity the user can still purchase + public var remainingQuantity: Int +} + /// Pre-order details for one-time purchase products (Android) /// Available in Google Play Billing Library 8.1.0+ public struct PreorderDetailsAndroid: Codable { @@ -408,7 +437,9 @@ public struct ProductAndroid: Codable, ProductCommon { public var displayPrice: String public var id: String public var nameAndroid: String - public var oneTimePurchaseOfferDetailsAndroid: ProductAndroidOneTimePurchaseOfferDetail? + /// One-time purchase offer details including discounts (Android) + /// Returns all eligible offers. Available in Google Play Billing Library 7.0+ + public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]? public var platform: IapPlatform = .android public var price: Double? public var subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails]? @@ -416,13 +447,33 @@ public struct ProductAndroid: Codable, ProductCommon { public var type: ProductType = .inApp } +/// One-time purchase offer details (Android) +/// Available in Google Play Billing Library 7.0+ public struct ProductAndroidOneTimePurchaseOfferDetail: Codable { + /// Discount display information + /// Only available for discounted offers + public var discountDisplayInfo: DiscountDisplayInfoAndroid? public var formattedPrice: String - /// Pre-order details for products available for pre-order (Android) + /// Full (non-discounted) price in micro-units + /// Only available for discounted offers + public var fullPriceMicros: String? + /// Limited quantity information + public var limitedQuantityInfo: LimitedQuantityInfoAndroid? + /// Offer ID + public var offerId: String? + /// List of offer tags + public var offerTags: [String] + /// Offer token for use in BillingFlowParams when purchasing + public var offerToken: String + /// Pre-order details for products available for pre-order /// Available in Google Play Billing Library 8.1.0+ public var preorderDetailsAndroid: PreorderDetailsAndroid? public var priceAmountMicros: String public var priceCurrencyCode: String + /// Rental details for rental offers + public var rentalDetailsAndroid: RentalDetailsAndroid? + /// Valid time window for the offer + public var validTimeWindow: ValidTimeWindowAndroid? } public struct ProductIOS: Codable, ProductCommon { @@ -451,7 +502,9 @@ public struct ProductSubscriptionAndroid: Codable, ProductCommon { public var displayPrice: String public var id: String public var nameAndroid: String - public var oneTimePurchaseOfferDetailsAndroid: ProductAndroidOneTimePurchaseOfferDetail? + /// One-time purchase offer details including discounts (Android) + /// Returns all eligible offers. Available in Google Play Billing Library 7.0+ + public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]? public var platform: IapPlatform = .android public var price: Double? public var subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails] @@ -609,6 +662,16 @@ public struct RenewalInfoIOS: Codable { public var willAutoRenew: Bool } +/// Rental details for one-time purchase products that can be rented (Android) +/// Available in Google Play Billing Library 7.0+ +public struct RentalDetailsAndroid: Codable { + /// Rental expiration period in ISO 8601 format + /// Time after rental period ends when user can still extend + public var rentalExpirationPeriod: String? + /// Rental period in ISO 8601 format (e.g., P7D for 7 days) + public var rentalPeriod: String +} + public enum RequestPurchaseResult { case purchase(Purchase?) case purchases([Purchase]?) @@ -658,6 +721,15 @@ public struct UserChoiceBillingDetails: Codable { public var products: [String] } +/// Valid time window for when an offer is available (Android) +/// Available in Google Play Billing Library 7.0+ +public struct ValidTimeWindowAndroid: Codable { + /// End time in milliseconds since epoch + public var endTimeMillis: String + /// Start time in milliseconds since epoch + public var startTimeMillis: String +} + public struct VerifyPurchaseResultAndroid: Codable { public var autoRenewing: Bool public var betaProduct: Bool diff --git a/packages/docs/src/pages/docs.tsx b/packages/docs/src/pages/docs.tsx index 327fc642..2b740cc8 100644 --- a/packages/docs/src/pages/docs.tsx +++ b/packages/docs/src/pages/docs.tsx @@ -10,6 +10,7 @@ import Events from './docs/events'; import Errors from './docs/errors'; import Purchase from './docs/features/purchase'; import SubscriptionFeature from './docs/features/subscription'; +import Discount from './docs/features/discount'; import OfferCodeRedemption from './docs/features/offer-code-redemption'; import ExternalPurchase from './docs/features/external-purchase'; import SubscriptionUpgradeDowngrade from './docs/features/subscription-upgrade-downgrade'; @@ -170,6 +171,15 @@ function Docs() { Subscription +
  • + (isActive ? 'active' : '')} + onClick={closeSidebar} + > + Discounts (Android) + +
  • } /> + } /> } diff --git a/packages/docs/src/pages/docs/features/discount.tsx b/packages/docs/src/pages/docs/features/discount.tsx new file mode 100644 index 00000000..ceb56195 --- /dev/null +++ b/packages/docs/src/pages/docs/features/discount.tsx @@ -0,0 +1,784 @@ +import AnchorLink from '../../../components/AnchorLink'; +import CodeBlock from '../../../components/CodeBlock'; +import LanguageTabs from '../../../components/LanguageTabs'; +import SEO from '../../../components/SEO'; +import { useScrollToHash } from '../../../hooks/useScrollToHash'; + +function Discount() { + useScrollToHash(); + + return ( +
    + +

    Discounts

    +

    + Display and handle discounted one-time purchase products on Google Play. + This feature requires Google Play Billing Library 7.0+ and allows you to + show original prices, discount percentages, and promotional offers. +

    + +
    +

    + Platform Support: This feature is currently + Android-only. iOS App Store handles discounts differently through + promotional offers and introductory prices for subscriptions. +

    +
    + +
    + + Overview + +

    + Google Play Billing Library 7.0+ introduced support for one-time + purchase discounts. When you configure a discount in Google Play + Console, the library provides: +

    +
      +
    • + Multiple Offers - Products can have multiple offers + with different prices +
    • +
    • + Discount Information - Percentage or fixed amount + discounts +
    • +
    • + Full Price - Original price before discount for + strikethrough display +
    • +
    • + Time Windows - Start and end times for limited-time + offers +
    • +
    • + Quantity Limits - Maximum and remaining quantities + for limited offers +
    • +
    +
    + +
    + + Data Structure + +

    + The oneTimePurchaseOfferDetailsAndroid field is now an + array containing all available offers for a product: +

    + + + {{ + typescript: ( + {`interface ProductAndroidOneTimePurchaseOfferDetail { + // Offer identification + offerId: string | null; + offerToken: string; + offerTags: string[]; + + // Pricing + formattedPrice: string; // "$4.99" + priceCurrencyCode: string; // "USD" + priceAmountMicros: string; // "4990000" + + // Discount information (only for discounted offers) + fullPriceMicros: string | null; // Original price: "9990000" + discountDisplayInfo: DiscountDisplayInfoAndroid | null; + + // Time and quantity limits + validTimeWindow: ValidTimeWindowAndroid | null; + limitedQuantityInfo: LimitedQuantityInfoAndroid | null; + + // Special offer types + preorderDetailsAndroid: PreorderDetailsAndroid | null; + rentalDetailsAndroid: RentalDetailsAndroid | null; +} + +interface DiscountDisplayInfoAndroid { + percentageDiscount: number | null; // 50 for 50% off + discountAmount: DiscountAmountAndroid | null; +} + +interface DiscountAmountAndroid { + discountAmountMicros: string; // "5000000" + formattedDiscountAmount: string; // "$5.00" +} + +interface ValidTimeWindowAndroid { + startTimeMillis: string; + endTimeMillis: string; +} + +interface LimitedQuantityInfoAndroid { + maximumQuantity: number; + remainingQuantity: number; +}`} + ), + swift: ( + {`// iOS does not support one-time purchase discounts in the same way. +// For iOS promotional offers, see the Subscription feature documentation.`} + ), + kotlin: ( + {`data class ProductAndroidOneTimePurchaseOfferDetail( + // Offer identification + val offerId: String?, + val offerToken: String, + val offerTags: List, + + // Pricing + val formattedPrice: String, // "$4.99" + val priceCurrencyCode: String, // "USD" + val priceAmountMicros: String, // "4990000" + + // Discount information (only for discounted offers) + val fullPriceMicros: String?, // Original price: "9990000" + val discountDisplayInfo: DiscountDisplayInfoAndroid?, + + // Time and quantity limits + val validTimeWindow: ValidTimeWindowAndroid?, + val limitedQuantityInfo: LimitedQuantityInfoAndroid?, + + // Special offer types + val preorderDetailsAndroid: PreorderDetailsAndroid?, + val rentalDetailsAndroid: RentalDetailsAndroid? +) + +data class DiscountDisplayInfoAndroid( + val percentageDiscount: Int?, // 50 for 50% off + val discountAmount: DiscountAmountAndroid? +) + +data class DiscountAmountAndroid( + val discountAmountMicros: String, // "5000000" + val formattedDiscountAmount: String // "$5.00" +) + +data class ValidTimeWindowAndroid( + val startTimeMillis: String, + val endTimeMillis: String +) + +data class LimitedQuantityInfoAndroid( + val maximumQuantity: Int, + val remainingQuantity: Int +)`} + ), + }} + +
    + +
    + + Fetching Products with Discounts + +

    + Fetch products normally using fetchProducts. Discounted + offers will be included in the{' '} + oneTimePurchaseOfferDetailsAndroid array: +

    + + + {{ + typescript: ( + {`import { fetchProducts } from 'expo-iap'; + +const products = await fetchProducts({ + skus: ['premium_feature', 'coins_100'], +}); + +products.forEach((product) => { + const offers = product.oneTimePurchaseOfferDetailsAndroid; + + if (offers && offers.length > 0) { + const firstOffer = offers[0]; + const hasDiscount = firstOffer.discountDisplayInfo != null; + + console.log('Product:', product.id); + console.log('Display Price:', product.displayPrice); + + if (hasDiscount) { + const discount = firstOffer.discountDisplayInfo; + const fullPriceMicros = parseInt(firstOffer.fullPriceMicros || '0', 10); + const fullPrice = fullPriceMicros / 1_000_000; + + console.log('Original Price:', fullPrice); + console.log('Discount:', discount?.percentageDiscount + '% OFF'); + } + } +});`} + ), + swift: ( + {`// iOS does not support one-time purchase discounts. +// Products are fetched the same way, but discount fields will not be present.`} + ), + kotlin: ( + {`import dev.hyo.openiap.store.OpenIapStore + +val iapStore = OpenIapStore.shared + +val products = iapStore.fetchProducts( + ProductRequest( + skus = listOf("premium_feature", "coins_100"), + type = ProductQueryType.InApp + ) +) + +products.forEach { product -> + val offers = product.oneTimePurchaseOfferDetailsAndroid + + if (!offers.isNullOrEmpty()) { + val firstOffer = offers.first() + val hasDiscount = firstOffer.discountDisplayInfo != null + + println("Product: \${product.id}") + println("Display Price: \${product.displayPrice}") + + if (hasDiscount) { + val discount = firstOffer.discountDisplayInfo + val fullPriceMicros = firstOffer.fullPriceMicros?.toLongOrNull() ?: 0L + val fullPrice = fullPriceMicros.toDouble() / 1_000_000.0 + + println("Original Price: $fullPrice") + println("Discount: \${discount?.percentageDiscount}% OFF") + } + } +}`} + ), + }} + +
    + +
    + + Displaying Discounts in UI + +

    + Show discount information to users with strikethrough original prices + and discount badges: +

    + + + {{ + typescript: ( + {`import { View, Text, StyleSheet } from 'react-native'; +import type { ProductAndroid } from 'expo-iap'; + +function ProductCard({ product }: { product: ProductAndroid }) { + const offers = product.oneTimePurchaseOfferDetailsAndroid; + const firstOffer = offers?.[0]; + const discount = firstOffer?.discountDisplayInfo; + const hasDiscount = discount != null; + + // Calculate original price for strikethrough + const fullPriceMicros = parseInt(firstOffer?.fullPriceMicros || '0', 10); + const fullPrice = fullPriceMicros / 1_000_000; + const currency = firstOffer?.priceCurrencyCode || ''; + + // Build discount text + const getDiscountText = () => { + if (!discount) return null; + if (discount.percentageDiscount) { + return \`\${discount.percentageDiscount}% OFF\`; + } + if (discount.discountAmount) { + return \`\${discount.discountAmount.formattedDiscountAmount} OFF\`; + } + return 'SALE'; + }; + + return ( + + {product.title} + {product.description} + + + {/* Original price with strikethrough */} + {hasDiscount && fullPriceMicros > 0 && ( + + {currency} {fullPrice.toFixed(2)} + + )} + + {/* Current (discounted) price */} + + {product.displayPrice} + + + {/* Discount badge */} + {hasDiscount && ( + + {getDiscountText()} + + )} + + + ); +} + +const styles = StyleSheet.create({ + card: { + padding: 16, + borderRadius: 12, + backgroundColor: '#fff', + marginVertical: 8, + }, + title: { + fontSize: 18, + fontWeight: '600', + }, + description: { + fontSize: 14, + color: '#666', + marginTop: 4, + }, + priceContainer: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 12, + gap: 8, + }, + originalPrice: { + fontSize: 14, + color: '#999', + textDecorationLine: 'line-through', + }, + price: { + fontSize: 20, + fontWeight: 'bold', + color: '#007AFF', + }, + discountedPrice: { + color: '#34C759', // Green for discounted price + }, + discountBadge: { + backgroundColor: 'rgba(255, 59, 48, 0.1)', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 4, + }, + discountText: { + fontSize: 12, + fontWeight: 'bold', + color: '#FF3B30', + }, +});`} + ), + swift: ( + {`// iOS does not support one-time purchase discounts in the same way. +// For subscription promotional offers on iOS, see the Subscription documentation.`} + ), + kotlin: ( + {`@Composable +fun ProductCard( + product: ProductAndroid, + onPurchase: () -> Unit +) { + val firstOffer = product.oneTimePurchaseOfferDetailsAndroid?.firstOrNull() + val discountInfo = firstOffer?.discountDisplayInfo + val hasDiscount = discountInfo != null + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = product.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + Text( + text = product.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Original price with strikethrough + if (hasDiscount && firstOffer?.fullPriceMicros != null) { + val fullPriceMicros = firstOffer.fullPriceMicros?.toLongOrNull() ?: 0L + val fullPrice = fullPriceMicros.toDouble() / 1_000_000.0 + Text( + text = "\${firstOffer.priceCurrencyCode} \${String.format("%.2f", fullPrice)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textDecoration = TextDecoration.LineThrough + ) + } + + // Current (discounted) price + Text( + text = product.displayPrice, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = if (hasDiscount) Color(0xFF34C759) else MaterialTheme.colorScheme.primary + ) + + // Discount badge + if (hasDiscount) { + val discountText = when { + discountInfo?.percentageDiscount != null -> + "\${discountInfo.percentageDiscount}% OFF" + discountInfo?.discountAmount != null -> + "\${discountInfo.discountAmount?.formattedDiscountAmount} OFF" + else -> "SALE" + } + Surface( + shape = RoundedCornerShape(4.dp), + color = Color(0xFFFF3B30).copy(alpha = 0.1f) + ) { + Text( + text = discountText, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color(0xFFFF3B30) + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Button( + onClick = onPurchase, + modifier = Modifier.fillMaxWidth() + ) { + Text("Buy Now") + } + } + } +}`} + ), + }} + +
    + +
    + + Time-Limited Offers + +

    + Check if an offer has a time window and display countdown or + expiration information: +

    + + + {{ + typescript: ( + {`function checkOfferValidity(offer: ProductAndroidOneTimePurchaseOfferDetail) { + const timeWindow = offer.validTimeWindow; + + if (!timeWindow) { + return { isValid: true, message: 'Always available' }; + } + + const now = Date.now(); + const startTime = parseInt(timeWindow.startTimeMillis, 10); + const endTime = parseInt(timeWindow.endTimeMillis, 10); + + if (now < startTime) { + const startsIn = new Date(startTime); + return { + isValid: false, + message: \`Starts on \${startsIn.toLocaleDateString()}\`, + }; + } + + if (now > endTime) { + return { isValid: false, message: 'Offer expired' }; + } + + const endsIn = endTime - now; + const hoursLeft = Math.floor(endsIn / (1000 * 60 * 60)); + const daysLeft = Math.floor(hoursLeft / 24); + + if (daysLeft > 0) { + return { isValid: true, message: \`Ends in \${daysLeft} days\` }; + } + + return { isValid: true, message: \`Ends in \${hoursLeft} hours\` }; +}`} + ), + swift: ( + {`// iOS does not have time-limited one-time purchase offers.`} + ), + kotlin: ( + {`data class OfferValidity( + val isValid: Boolean, + val message: String +) + +fun checkOfferValidity(offer: ProductAndroidOneTimePurchaseOfferDetail): OfferValidity { + val timeWindow = offer.validTimeWindow + ?: return OfferValidity(true, "Always available") + + val now = System.currentTimeMillis() + val startTime = timeWindow.startTimeMillis.toLongOrNull() ?: 0L + val endTime = timeWindow.endTimeMillis.toLongOrNull() ?: 0L + + if (now < startTime) { + val startsIn = java.util.Date(startTime) + return OfferValidity(false, "Starts on \${startsIn}") + } + + if (now > endTime) { + return OfferValidity(false, "Offer expired") + } + + val endsIn = endTime - now + val hoursLeft = (endsIn / (1000 * 60 * 60)).toInt() + val daysLeft = hoursLeft / 24 + + return if (daysLeft > 0) { + OfferValidity(true, "Ends in $daysLeft days") + } else { + OfferValidity(true, "Ends in $hoursLeft hours") + } +}`} + ), + }} + +
    + +
    + + Limited Quantity Offers + +

    + Some offers have quantity limits. Check remaining availability before + displaying: +

    + + + {{ + typescript: ( + {`function checkQuantityAvailability(offer: ProductAndroidOneTimePurchaseOfferDetail) { + const quantityInfo = offer.limitedQuantityInfo; + + if (!quantityInfo) { + return { isAvailable: true, message: null }; + } + + const { maximumQuantity, remainingQuantity } = quantityInfo; + + if (remainingQuantity <= 0) { + return { + isAvailable: false, + message: 'Sold out - limit reached', + }; + } + + if (remainingQuantity <= 3) { + return { + isAvailable: true, + message: \`Only \${remainingQuantity} left!\`, + }; + } + + return { + isAvailable: true, + message: \`\${remainingQuantity} of \${maximumQuantity} available\`, + }; +}`} + ), + swift: ( + {`// iOS does not have limited quantity one-time purchase offers.`} + ), + kotlin: ( + {`data class QuantityAvailability( + val isAvailable: Boolean, + val message: String? +) + +fun checkQuantityAvailability(offer: ProductAndroidOneTimePurchaseOfferDetail): QuantityAvailability { + val quantityInfo = offer.limitedQuantityInfo + ?: return QuantityAvailability(true, null) + + val (maximumQuantity, remainingQuantity) = quantityInfo + + if (remainingQuantity <= 0) { + return QuantityAvailability(false, "Sold out - limit reached") + } + + if (remainingQuantity <= 3) { + return QuantityAvailability(true, "Only $remainingQuantity left!") + } + + return QuantityAvailability(true, "$remainingQuantity of $maximumQuantity available") +}`} + ), + }} + +
    + +
    + + Purchasing with Specific Offer + +

    + When purchasing a discounted product, use the offerToken{' '} + from the specific offer you want to apply: +

    + + + {{ + typescript: ( + {`import { requestPurchase } from 'expo-iap'; + +async function purchaseWithOffer( + product: ProductAndroid, + offerIndex: number = 0 +) { + const offers = product.oneTimePurchaseOfferDetailsAndroid; + + if (!offers || offers.length === 0) { + throw new Error('No offers available for this product'); + } + + const selectedOffer = offers[offerIndex]; + + await requestPurchase({ + type: 'inapp', + request: { + skus: [product.id], + // Include offerToken for discounted purchases + offerToken: selectedOffer.offerToken, + }, + }); +}`} + ), + swift: ( + {`// iOS does not use offer tokens for one-time purchases. +// Simply request the purchase with the product ID.`} + ), + kotlin: ( + {`suspend fun purchaseWithOffer( + activity: Activity, + product: ProductAndroid, + offerIndex: Int = 0 +) { + val offers = product.oneTimePurchaseOfferDetailsAndroid + ?: throw IllegalStateException("No offers available") + + val selectedOffer = offers.getOrNull(offerIndex) + ?: throw IllegalStateException("Invalid offer index") + + iapStore.requestPurchase( + activity = activity, + props = RequestPurchaseProps( + type = "inapp", + request = RequestPurchasePropsByPlatforms( + android = RequestPurchaseAndroidProps( + skus = listOf(product.id), + offerToken = selectedOffer.offerToken + ) + ) + ) + ) +}`} + ), + }} + +
    + +
    + + Setting Up Discounts in Google Play Console + +

    To create discounted offers for one-time products:

    +
      +
    1. Go to Google Play Console > Monetization > Products
    2. +
    3. Select your one-time product or create a new one
    4. +
    5. + In the product details, look for the Offers section +
    6. +
    7. + Click Add offer to create a promotional offer +
    8. +
    9. + Configure: +
        +
      • + Discount type: Percentage or fixed amount +
      • +
      • + Discount value: The discount percentage or + amount +
      • +
      • + Time window: Start and end dates (optional) +
      • +
      • + Quantity limit: Maximum purchases per user + (optional) +
      • +
      +
    10. +
    11. + Save and publish your changes - it may take a few hours for changes + to propagate +
    12. +
    + +
    +

    + Note: Discount features require Google Play Billing + Library 7.0+. Make sure your app uses a compatible version of the + OpenIAP library. +

    +
    +
    + +
    + + Best Practices + +
      +
    • + Always show original price - Display the + strikethrough original price next to the discounted price to + highlight the value +
    • +
    • + Use urgency indicators - Show countdown timers for + time-limited offers +
    • +
    • + Handle multiple offers - If a product has multiple + offers, let users choose or automatically select the best discount +
    • +
    • + Graceful degradation - If discount info is not + available, display the regular price without errors +
    • +
    • + Cache carefully - Discount offers can change; fetch + fresh product data periodically +
    • +
    • + Test with license testers - Use Google Play Console + license testers to verify discount display before release +
    • +
    +
    +
    + ); +} + +export default Discount; diff --git a/packages/docs/src/pages/docs/types.tsx b/packages/docs/src/pages/docs/types.tsx index 437f7019..6e1f99ad 100644 --- a/packages/docs/src/pages/docs/types.tsx +++ b/packages/docs/src/pages/docs/types.tsx @@ -367,21 +367,26 @@ function Types() { oneTimePurchaseOfferDetailsAndroid - For one-time purchases. Contains:{' '} + Array of one-time purchase offers. Each offer contains:{' '} formattedPrice,{' '} - priceAmountMicros (divide by 1,000,000),{' '} + priceAmountMicros,{' '} priceCurrencyCode,{' '} - preorderDetailsAndroid (for pre-order - products, contains preorderPresaleEndTimeMillis{' '} - and preorderReleaseTimeMillis -{' '} + offerToken,{' '} + discountDisplayInfo (discount info),{' '} + fullPriceMicros (original price),{' '} + validTimeWindow,{' '} + limitedQuantityInfo,{' '} + preorderDetailsAndroid,{' '} + rentalDetailsAndroid. + See Discounts. + Requires{' '} - Billing Library 8.1.0+ + Billing Library 7.0+ - ) diff --git a/packages/docs/src/pages/docs/updates/notes.tsx b/packages/docs/src/pages/docs/updates/notes.tsx index 63bf9251..ccd8bbb6 100644 --- a/packages/docs/src/pages/docs/updates/notes.tsx +++ b/packages/docs/src/pages/docs/updates/notes.tsx @@ -92,9 +92,9 @@ if (purchase.isSuspendedAndroid == true) { deepLinkToSubscriptions() } -// Pre-order details +// Pre-order details (oneTimePurchaseOfferDetailsAndroid is now an array) val product = fetchProducts(skus) -product.oneTimePurchaseOfferDetailsAndroid?.preorderDetailsAndroid?.let { +product.oneTimePurchaseOfferDetailsAndroid?.firstOrNull()?.preorderDetailsAndroid?.let { val releaseTime = it.preorderReleaseTimeMillis.toLong() val presaleEndTime = it.preorderPresaleEndTimeMillis.toLong() }`} diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt index 6656b349..769e67f8 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt @@ -572,8 +572,8 @@ fun PurchaseFlowScreen( purchaseToken = token ) ) - val results = verifyPurchaseWithIapkit(props, "PurchaseFlowScreen") - return results.firstOrNull()?.isValid == true + val result = verifyPurchaseWithIapkit(props, "PurchaseFlowScreen") + return result.isValid } // Local verification: For Android, we just check if the purchase state is authentic diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt index a3910ab1..9ba088bd 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt @@ -1281,8 +1281,8 @@ fun SubscriptionFlowScreen( purchaseToken = token ) ) - val results = verifyPurchaseWithIapkit(props, "SubscriptionFlowScreen") - return results.firstOrNull()?.isValid == true + val result = verifyPurchaseWithIapkit(props, "SubscriptionFlowScreen") + return result.isValid } // Local verification: For Android, we just check if the purchase state is authentic diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/uis/Modals.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/uis/Modals.kt index 95791cb3..8c96e527 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/uis/Modals.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/uis/Modals.kt @@ -136,15 +136,84 @@ fun ProductDetailModal( product.nameAndroid?.let { DetailRow("Android Name", it) } } - product.oneTimePurchaseOfferDetailsAndroid?.let { offer -> + product.oneTimePurchaseOfferDetailsAndroid?.takeIf { it.isNotEmpty() }?.let { offers -> HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) Text( - "One-Time Purchase Details", + "One-Time Purchase Offers (${offers.size})", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold ) - DetailRow("Formatted Price", offer.formattedPrice) - DetailRow("Price (micros)", offer.priceAmountMicros) + offers.forEachIndexed { index, offer -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + colors = CardDefaults.cardColors( + containerColor = if (offer.discountDisplayInfo != null) + AppColors.success.copy(alpha = 0.1f) + else + AppColors.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + "Offer ${index + 1}", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold + ) + DetailRow("Formatted Price", offer.formattedPrice) + DetailRow("Price (micros)", offer.priceAmountMicros) + offer.offerId?.let { DetailRow("Offer ID", it) } + DetailRow("Offer Token", offer.offerToken) + if (offer.offerTags.isNotEmpty()) { + DetailRow("Tags", offer.offerTags.joinToString(", ")) + } + + // Discount information + offer.fullPriceMicros?.let { fullPrice -> + DetailRow("Full Price (micros)", fullPrice) + } + offer.discountDisplayInfo?.let { discount -> + discount.percentageDiscount?.let { + DetailRow("Discount", "$it% OFF") + } + discount.discountAmount?.let { amount -> + DetailRow("Discount Amount", amount.formattedDiscountAmount) + DetailRow("Discount (micros)", amount.discountAmountMicros) + } + } + + // Time window + offer.validTimeWindow?.let { window -> + DetailRow("Valid From", window.startTimeMillis) + DetailRow("Valid Until", window.endTimeMillis) + } + + // Limited quantity + offer.limitedQuantityInfo?.let { limit -> + DetailRow("Max Quantity", limit.maximumQuantity.toString()) + DetailRow("Remaining", limit.remainingQuantity.toString()) + } + + // Preorder details + offer.preorderDetailsAndroid?.let { preorder -> + DetailRow("Presale Ends", preorder.preorderPresaleEndTimeMillis) + DetailRow("Release Time", preorder.preorderReleaseTimeMillis) + } + + // Rental details + offer.rentalDetailsAndroid?.let { rental -> + DetailRow("Rental Period", rental.rentalPeriod) + rental.rentalExpirationPeriod?.let { + DetailRow("Expiration Period", it) + } + } + } + } + } } product.subscriptionOfferDetailsAndroid?.takeIf { it.isNotEmpty() }?.let { offers -> diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/uis/ProductCard.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/uis/ProductCard.kt index 563a73ea..51e03387 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/uis/ProductCard.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/uis/ProductCard.kt @@ -111,13 +111,51 @@ fun ProductCard( horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp) ) { + // Check for discount information + val firstOffer = product.oneTimePurchaseOfferDetailsAndroid?.firstOrNull() + val discountInfo = firstOffer?.discountDisplayInfo + val hasDiscount = discountInfo != null + + if (hasDiscount && firstOffer?.fullPriceMicros != null) { + // Show original price with strikethrough + val fullPriceMicros = firstOffer.fullPriceMicros?.toLongOrNull() ?: 0L + val fullPrice = fullPriceMicros.toDouble() / 1_000_000.0 + Text( + "${firstOffer.priceCurrencyCode} ${String.format("%.2f", fullPrice)}", + style = MaterialTheme.typography.bodyMedium, + color = AppColors.textSecondary, + textDecoration = androidx.compose.ui.text.style.TextDecoration.LineThrough + ) + } + Text( product.displayPrice, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = AppColors.primary + color = if (hasDiscount) AppColors.success else AppColors.primary ) + // Show discount badge + if (hasDiscount) { + val discountText = when { + discountInfo?.percentageDiscount != null -> "${discountInfo.percentageDiscount}% OFF" + discountInfo?.discountAmount != null -> "${discountInfo.discountAmount?.formattedDiscountAmount} OFF" + else -> "SALE" + } + Surface( + shape = RoundedCornerShape(4.dp), + color = AppColors.danger.copy(alpha = 0.2f) + ) { + Text( + discountText, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = AppColors.danger + ) + } + } + if (isPurchasing) { CircularProgressIndicator( modifier = Modifier.size(24.dp), diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt index b8d93fce..d1189002 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt @@ -23,6 +23,26 @@ internal object HorizonBillingConverters { val currency = offer?.priceCurrencyCode.orEmpty() val priceAmountMicros = offer?.priceAmountMicros ?: 0L + // Convert single offer to list format (Horizon doesn't support discount offers yet) + val offerDetailsList = offer?.let { + listOf( + ProductAndroidOneTimePurchaseOfferDetail( + offerId = null, + offerToken = "", + offerTags = emptyList(), + formattedPrice = it.formattedPrice, + priceAmountMicros = it.priceAmountMicros.toString(), + priceCurrencyCode = it.priceCurrencyCode, + fullPriceMicros = null, + discountDisplayInfo = null, + validTimeWindow = null, + limitedQuantityInfo = null, + preorderDetailsAndroid = null, + rentalDetailsAndroid = null + ) + ) + } + return ProductAndroid( currency = currency, debugDescription = description, @@ -31,13 +51,7 @@ internal object HorizonBillingConverters { displayPrice = displayPrice, id = productId, nameAndroid = name, - oneTimePurchaseOfferDetailsAndroid = offer?.let { - ProductAndroidOneTimePurchaseOfferDetail( - formattedPrice = it.formattedPrice, - priceAmountMicros = it.priceAmountMicros.toString(), - priceCurrencyCode = it.priceCurrencyCode - ) - }, + oneTimePurchaseOfferDetailsAndroid = offerDetailsList, platform = IapPlatform.Android, price = priceAmountMicros.toDouble() / 1_000_000.0, subscriptionOfferDetailsAndroid = null, @@ -73,6 +87,26 @@ internal object HorizonBillingConverters { ) } + // Convert single offer to list format (Horizon doesn't support discount offers yet) + val oneTimeOfferDetailsList = oneTimePurchaseOfferDetails?.let { + listOf( + ProductAndroidOneTimePurchaseOfferDetail( + offerId = null, + offerToken = "", + offerTags = emptyList(), + formattedPrice = it.formattedPrice, + priceAmountMicros = it.priceAmountMicros.toString(), + priceCurrencyCode = it.priceCurrencyCode, + fullPriceMicros = null, + discountDisplayInfo = null, + validTimeWindow = null, + limitedQuantityInfo = null, + preorderDetailsAndroid = null, + rentalDetailsAndroid = null + ) + ) + } + return ProductSubscriptionAndroid( currency = currency, debugDescription = description, @@ -81,13 +115,7 @@ internal object HorizonBillingConverters { displayPrice = displayPrice, id = productId, nameAndroid = name, - oneTimePurchaseOfferDetailsAndroid = oneTimePurchaseOfferDetails?.let { - ProductAndroidOneTimePurchaseOfferDetail( - formattedPrice = it.formattedPrice, - priceAmountMicros = it.priceAmountMicros.toString(), - priceCurrencyCode = it.priceCurrencyCode - ) - }, + oneTimePurchaseOfferDetailsAndroid = oneTimeOfferDetailsList, platform = IapPlatform.Android, price = firstPhase?.priceAmountMicros?.toDouble()?.div(1_000_000.0), subscriptionOfferDetailsAndroid = pricingDetails, 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 0de9a3f4..3139eae7 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 @@ -670,6 +670,70 @@ public data class AppTransaction( ) } +/** + * Discount amount details for one-time purchase offers (Android) + * Available in Google Play Billing Library 7.0+ + */ +public data class DiscountAmountAndroid( + /** + * Discount amount in micro-units (1,000,000 = 1 unit of currency) + */ + val discountAmountMicros: String, + /** + * Formatted discount amount with currency sign (e.g., "$4.99") + */ + val formattedDiscountAmount: String +) { + + companion object { + fun fromJson(json: Map): DiscountAmountAndroid { + return DiscountAmountAndroid( + discountAmountMicros = json["discountAmountMicros"] as String, + formattedDiscountAmount = json["formattedDiscountAmount"] as String, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "DiscountAmountAndroid", + "discountAmountMicros" to discountAmountMicros, + "formattedDiscountAmount" to formattedDiscountAmount, + ) +} + +/** + * Discount display information for one-time purchase offers (Android) + * Available in Google Play Billing Library 7.0+ + */ +public data class DiscountDisplayInfoAndroid( + /** + * Absolute discount amount details + * Only returned for fixed amount discounts + */ + val discountAmount: DiscountAmountAndroid? = null, + /** + * Percentage discount (e.g., 33 for 33% off) + * Only returned for percentage-based discounts + */ + val percentageDiscount: Int? = null +) { + + companion object { + fun fromJson(json: Map): DiscountDisplayInfoAndroid { + return DiscountDisplayInfoAndroid( + discountAmount = (json["discountAmount"] as Map?)?.let { DiscountAmountAndroid.fromJson(it) }, + percentageDiscount = (json["percentageDiscount"] as Number?)?.toInt(), + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "DiscountDisplayInfoAndroid", + "discountAmount" to discountAmount?.toJson(), + "percentageDiscount" to percentageDiscount, + ) +} + public data class DiscountIOS( val identifier: String, val localizedPrice: String? = null, @@ -846,6 +910,37 @@ public data class FetchProductsResultProducts(val value: List?) : Fetch public data class FetchProductsResultSubscriptions(val value: List?) : FetchProductsResult +/** + * Limited quantity information for one-time purchase offers (Android) + * Available in Google Play Billing Library 7.0+ + */ +public data class LimitedQuantityInfoAndroid( + /** + * Maximum quantity a user can purchase + */ + val maximumQuantity: Int, + /** + * Remaining quantity the user can still purchase + */ + val remainingQuantity: Int +) { + + companion object { + fun fromJson(json: Map): LimitedQuantityInfoAndroid { + return LimitedQuantityInfoAndroid( + maximumQuantity = (json["maximumQuantity"] as Number).toInt(), + remainingQuantity = (json["remainingQuantity"] as Number).toInt(), + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "LimitedQuantityInfoAndroid", + "maximumQuantity" to maximumQuantity, + "remainingQuantity" to remainingQuantity, + ) +} + /** * Pre-order details for one-time purchase products (Android) * Available in Google Play Billing Library 8.1.0+ @@ -938,7 +1033,11 @@ public data class ProductAndroid( override val displayPrice: String, override val id: String, val nameAndroid: String, - val oneTimePurchaseOfferDetailsAndroid: ProductAndroidOneTimePurchaseOfferDetail? = null, + /** + * One-time purchase offer details including discounts (Android) + * Returns all eligible offers. Available in Google Play Billing Library 7.0+ + */ + val oneTimePurchaseOfferDetailsAndroid: List? = null, override val platform: IapPlatform = IapPlatform.Android, override val price: Double? = null, val subscriptionOfferDetailsAndroid: List? = null, @@ -956,7 +1055,7 @@ public data class ProductAndroid( displayPrice = json["displayPrice"] as String, id = json["id"] as String, nameAndroid = json["nameAndroid"] as String, - oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as Map?)?.let { ProductAndroidOneTimePurchaseOfferDetail.fromJson(it) }, + oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as List<*>?)?.map { ProductAndroidOneTimePurchaseOfferDetail.fromJson((it as Map)) }, platform = IapPlatform.fromJson(json["platform"] as String), price = (json["price"] as Number?)?.toDouble(), subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as List<*>?)?.map { ProductSubscriptionAndroidOfferDetails.fromJson((it as Map)) }, @@ -975,7 +1074,7 @@ public data class ProductAndroid( "displayPrice" to displayPrice, "id" to id, "nameAndroid" to nameAndroid, - "oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseOfferDetailsAndroid?.toJson(), + "oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseOfferDetailsAndroid?.map { it.toJson() }, "platform" to platform.toJson(), "price" to price, "subscriptionOfferDetailsAndroid" to subscriptionOfferDetailsAndroid?.map { it.toJson() }, @@ -984,34 +1083,88 @@ public data class ProductAndroid( ) } +/** + * One-time purchase offer details (Android) + * Available in Google Play Billing Library 7.0+ + */ public data class ProductAndroidOneTimePurchaseOfferDetail( + /** + * Discount display information + * Only available for discounted offers + */ + val discountDisplayInfo: DiscountDisplayInfoAndroid? = null, val formattedPrice: String, /** - * Pre-order details for products available for pre-order (Android) + * Full (non-discounted) price in micro-units + * Only available for discounted offers + */ + val fullPriceMicros: String? = null, + /** + * Limited quantity information + */ + val limitedQuantityInfo: LimitedQuantityInfoAndroid? = null, + /** + * Offer ID + */ + val offerId: String? = null, + /** + * List of offer tags + */ + val offerTags: List, + /** + * Offer token for use in BillingFlowParams when purchasing + */ + val offerToken: String, + /** + * Pre-order details for products available for pre-order * Available in Google Play Billing Library 8.1.0+ */ val preorderDetailsAndroid: PreorderDetailsAndroid? = null, val priceAmountMicros: String, - val priceCurrencyCode: String + val priceCurrencyCode: String, + /** + * Rental details for rental offers + */ + val rentalDetailsAndroid: RentalDetailsAndroid? = null, + /** + * Valid time window for the offer + */ + val validTimeWindow: ValidTimeWindowAndroid? = null ) { companion object { fun fromJson(json: Map): ProductAndroidOneTimePurchaseOfferDetail { return ProductAndroidOneTimePurchaseOfferDetail( + discountDisplayInfo = (json["discountDisplayInfo"] as Map?)?.let { DiscountDisplayInfoAndroid.fromJson(it) }, formattedPrice = json["formattedPrice"] as String, + fullPriceMicros = json["fullPriceMicros"] as String?, + limitedQuantityInfo = (json["limitedQuantityInfo"] as Map?)?.let { LimitedQuantityInfoAndroid.fromJson(it) }, + offerId = json["offerId"] as String?, + offerTags = (json["offerTags"] as List<*>).map { it as String }, + offerToken = json["offerToken"] as String, preorderDetailsAndroid = (json["preorderDetailsAndroid"] as Map?)?.let { PreorderDetailsAndroid.fromJson(it) }, priceAmountMicros = json["priceAmountMicros"] as String, priceCurrencyCode = json["priceCurrencyCode"] as String, + rentalDetailsAndroid = (json["rentalDetailsAndroid"] as Map?)?.let { RentalDetailsAndroid.fromJson(it) }, + validTimeWindow = (json["validTimeWindow"] as Map?)?.let { ValidTimeWindowAndroid.fromJson(it) }, ) } } fun toJson(): Map = mapOf( "__typename" to "ProductAndroidOneTimePurchaseOfferDetail", + "discountDisplayInfo" to discountDisplayInfo?.toJson(), "formattedPrice" to formattedPrice, + "fullPriceMicros" to fullPriceMicros, + "limitedQuantityInfo" to limitedQuantityInfo?.toJson(), + "offerId" to offerId, + "offerTags" to offerTags.map { it }, + "offerToken" to offerToken, "preorderDetailsAndroid" to preorderDetailsAndroid?.toJson(), "priceAmountMicros" to priceAmountMicros, "priceCurrencyCode" to priceCurrencyCode, + "rentalDetailsAndroid" to rentalDetailsAndroid?.toJson(), + "validTimeWindow" to validTimeWindow?.toJson(), ) } @@ -1083,7 +1236,11 @@ public data class ProductSubscriptionAndroid( override val displayPrice: String, override val id: String, val nameAndroid: String, - val oneTimePurchaseOfferDetailsAndroid: ProductAndroidOneTimePurchaseOfferDetail? = null, + /** + * One-time purchase offer details including discounts (Android) + * Returns all eligible offers. Available in Google Play Billing Library 7.0+ + */ + val oneTimePurchaseOfferDetailsAndroid: List? = null, override val platform: IapPlatform = IapPlatform.Android, override val price: Double? = null, val subscriptionOfferDetailsAndroid: List, @@ -1101,7 +1258,7 @@ public data class ProductSubscriptionAndroid( displayPrice = json["displayPrice"] as String, id = json["id"] as String, nameAndroid = json["nameAndroid"] as String, - oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as Map?)?.let { ProductAndroidOneTimePurchaseOfferDetail.fromJson(it) }, + oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as List<*>?)?.map { ProductAndroidOneTimePurchaseOfferDetail.fromJson((it as Map)) }, platform = IapPlatform.fromJson(json["platform"] as String), price = (json["price"] as Number?)?.toDouble(), subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as List<*>).map { ProductSubscriptionAndroidOfferDetails.fromJson((it as Map)) }, @@ -1120,7 +1277,7 @@ public data class ProductSubscriptionAndroid( "displayPrice" to displayPrice, "id" to id, "nameAndroid" to nameAndroid, - "oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseOfferDetailsAndroid?.toJson(), + "oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseOfferDetailsAndroid?.map { it.toJson() }, "platform" to platform.toJson(), "price" to price, "subscriptionOfferDetailsAndroid" to subscriptionOfferDetailsAndroid.map { it.toJson() }, @@ -1603,6 +1760,38 @@ public data class RenewalInfoIOS( ) } +/** + * Rental details for one-time purchase products that can be rented (Android) + * Available in Google Play Billing Library 7.0+ + */ +public data class RentalDetailsAndroid( + /** + * Rental expiration period in ISO 8601 format + * Time after rental period ends when user can still extend + */ + val rentalExpirationPeriod: String? = null, + /** + * Rental period in ISO 8601 format (e.g., P7D for 7 days) + */ + val rentalPeriod: String +) { + + companion object { + fun fromJson(json: Map): RentalDetailsAndroid { + return RentalDetailsAndroid( + rentalExpirationPeriod = json["rentalExpirationPeriod"] as String?, + rentalPeriod = json["rentalPeriod"] as String, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "RentalDetailsAndroid", + "rentalExpirationPeriod" to rentalExpirationPeriod, + "rentalPeriod" to rentalPeriod, + ) +} + public sealed interface RequestPurchaseResult public data class RequestPurchaseResultPurchase(val value: Purchase?) : RequestPurchaseResult @@ -1775,6 +1964,37 @@ public data class UserChoiceBillingDetails( ) } +/** + * Valid time window for when an offer is available (Android) + * Available in Google Play Billing Library 7.0+ + */ +public data class ValidTimeWindowAndroid( + /** + * End time in milliseconds since epoch + */ + val endTimeMillis: String, + /** + * Start time in milliseconds since epoch + */ + val startTimeMillis: String +) { + + companion object { + fun fromJson(json: Map): ValidTimeWindowAndroid { + return ValidTimeWindowAndroid( + endTimeMillis = json["endTimeMillis"] as String, + startTimeMillis = json["startTimeMillis"] as String, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "ValidTimeWindowAndroid", + "endTimeMillis" to endTimeMillis, + "startTimeMillis" to startTimeMillis, + ) +} + public data class VerifyPurchaseResultAndroid( val autoRenewing: Boolean, val betaProduct: Boolean, 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 07cc7ea0..43b5872b 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 @@ -1,8 +1,11 @@ package dev.hyo.openiap.utils import dev.hyo.openiap.ActiveSubscription +import dev.hyo.openiap.DiscountAmountAndroid +import dev.hyo.openiap.DiscountDisplayInfoAndroid import dev.hyo.openiap.IapPlatform import dev.hyo.openiap.IapStore +import dev.hyo.openiap.LimitedQuantityInfoAndroid import dev.hyo.openiap.PricingPhaseAndroid import dev.hyo.openiap.PricingPhasesAndroid import dev.hyo.openiap.Product @@ -16,23 +19,97 @@ import dev.hyo.openiap.Purchase import dev.hyo.openiap.PurchaseAndroid import dev.hyo.openiap.PurchaseInput import dev.hyo.openiap.PurchaseState +import dev.hyo.openiap.RentalDetailsAndroid +import dev.hyo.openiap.ValidTimeWindowAndroid import com.android.billingclient.api.BillingClient import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.Purchase as BillingPurchase internal object BillingConverters { + /** + * Converts a ProductDetails.OneTimePurchaseOfferDetails to ProductAndroidOneTimePurchaseOfferDetail + * This includes all discount-related fields available in Billing Library 7.0+ + */ + private fun ProductDetails.OneTimePurchaseOfferDetails.toOfferDetail(): ProductAndroidOneTimePurchaseOfferDetail { + // Extract discount display info if available + val discountInfo = discountDisplayInfo?.let { info -> + DiscountDisplayInfoAndroid( + percentageDiscount = runCatching { info.percentageDiscount }.getOrNull(), + discountAmount = runCatching { + info.discountAmount?.let { amount -> + DiscountAmountAndroid( + discountAmountMicros = amount.discountAmountMicros.toString(), + formattedDiscountAmount = amount.formattedDiscountAmount + ) + } + }.getOrNull() + ) + } + + // Extract valid time window if available + val timeWindow = validTimeWindow?.let { window -> + ValidTimeWindowAndroid( + startTimeMillis = window.startTimeMillis.toString(), + endTimeMillis = window.endTimeMillis.toString() + ) + } + + // Extract limited quantity info if available + val quantityInfo = limitedQuantityInfo?.let { info -> + LimitedQuantityInfoAndroid( + maximumQuantity = info.maximumQuantity, + remainingQuantity = info.remainingQuantity + ) + } + + // Extract preorder details if available + val preorder = preorderDetails?.let { details -> + PreorderDetailsAndroid( + preorderPresaleEndTimeMillis = details.preorderPresaleEndTimeMillis.toString(), + preorderReleaseTimeMillis = details.preorderReleaseTimeMillis.toString() + ) + } + + // Extract rental details if available + val rental = rentalDetails?.let { details -> + RentalDetailsAndroid( + rentalPeriod = details.rentalPeriod, + rentalExpirationPeriod = runCatching { details.rentalExpirationPeriod }.getOrNull() + ) + } + + return ProductAndroidOneTimePurchaseOfferDetail( + offerId = runCatching { offerId }.getOrNull(), + offerToken = offerToken ?: "", + offerTags = runCatching { offerTags.orEmpty() }.getOrElse { emptyList() }, + formattedPrice = formattedPrice, + priceCurrencyCode = priceCurrencyCode, + priceAmountMicros = priceAmountMicros.toString(), + fullPriceMicros = runCatching { fullPriceMicros?.toString() }.getOrNull(), + discountDisplayInfo = discountInfo, + validTimeWindow = timeWindow, + limitedQuantityInfo = quantityInfo, + preorderDetailsAndroid = preorder, + rentalDetailsAndroid = rental + ) + } + fun ProductDetails.toInAppProduct(): ProductAndroid { + // Get all offers using getOneTimePurchaseOfferDetailsList() for discount support + val allOffers = runCatching { oneTimePurchaseOfferDetailsList }.getOrNull().orEmpty() + + // Fall back to legacy oneTimePurchaseOfferDetails if list is empty val offer = oneTimePurchaseOfferDetails val displayPrice = offer?.formattedPrice.orEmpty() val currency = offer?.priceCurrencyCode.orEmpty() val priceAmountMicros = offer?.priceAmountMicros ?: 0L - // Extract preorder details (available in Billing Library 8.1.0+) - val preorderDetails = offer?.preorderDetails?.let { preorder -> - PreorderDetailsAndroid( - preorderPresaleEndTimeMillis = preorder.preorderPresaleEndTimeMillis.toString(), - preorderReleaseTimeMillis = preorder.preorderReleaseTimeMillis.toString() - ) + // Convert all offers to the list format + val offerDetailsList = if (allOffers.isNotEmpty()) { + allOffers.map { it.toOfferDetail() } + } else { + // Fall back to legacy single offer if list is empty + offer?.let { listOf(it.toOfferDetail()) } } return ProductAndroid( @@ -43,14 +120,7 @@ internal object BillingConverters { displayPrice = displayPrice, id = productId, nameAndroid = name, - oneTimePurchaseOfferDetailsAndroid = offer?.let { - ProductAndroidOneTimePurchaseOfferDetail( - formattedPrice = it.formattedPrice, - priceAmountMicros = it.priceAmountMicros.toString(), - priceCurrencyCode = it.priceCurrencyCode, - preorderDetailsAndroid = preorderDetails - ) - }, + oneTimePurchaseOfferDetailsAndroid = offerDetailsList, platform = IapPlatform.Android, price = priceAmountMicros.toDouble() / 1_000_000.0, subscriptionOfferDetailsAndroid = null, @@ -85,6 +155,14 @@ internal object BillingConverters { ) } + // Get all one-time offers for subscriptions that may have them + val allOneTimeOffers = runCatching { oneTimePurchaseOfferDetailsList }.getOrNull().orEmpty() + val oneTimeOfferDetailsList = if (allOneTimeOffers.isNotEmpty()) { + allOneTimeOffers.map { it.toOfferDetail() } + } else { + oneTimePurchaseOfferDetails?.let { listOf(it.toOfferDetail()) } + } + return ProductSubscriptionAndroid( currency = currency, debugDescription = description, @@ -93,13 +171,7 @@ internal object BillingConverters { displayPrice = displayPrice, id = productId, nameAndroid = name, - oneTimePurchaseOfferDetailsAndroid = oneTimePurchaseOfferDetails?.let { - ProductAndroidOneTimePurchaseOfferDetail( - formattedPrice = it.formattedPrice, - priceAmountMicros = it.priceAmountMicros.toString(), - priceCurrencyCode = it.priceCurrencyCode - ) - }, + oneTimePurchaseOfferDetailsAndroid = oneTimeOfferDetailsList, platform = IapPlatform.Android, price = firstPhase?.priceAmountMicros?.toDouble()?.div(1_000_000.0), subscriptionOfferDetailsAndroid = pricingDetails, diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index 3d811ee3..ce956aa6 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -737,6 +737,70 @@ public data class AppTransaction( ) } +/** + * Discount amount details for one-time purchase offers (Android) + * Available in Google Play Billing Library 7.0+ + */ +public data class DiscountAmountAndroid( + /** + * Discount amount in micro-units (1,000,000 = 1 unit of currency) + */ + val discountAmountMicros: String, + /** + * Formatted discount amount with currency sign (e.g., "$4.99") + */ + val formattedDiscountAmount: String +) { + + companion object { + fun fromJson(json: Map): DiscountAmountAndroid { + return DiscountAmountAndroid( + discountAmountMicros = json["discountAmountMicros"] as String, + formattedDiscountAmount = json["formattedDiscountAmount"] as String, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "DiscountAmountAndroid", + "discountAmountMicros" to discountAmountMicros, + "formattedDiscountAmount" to formattedDiscountAmount, + ) +} + +/** + * Discount display information for one-time purchase offers (Android) + * Available in Google Play Billing Library 7.0+ + */ +public data class DiscountDisplayInfoAndroid( + /** + * Absolute discount amount details + * Only returned for fixed amount discounts + */ + val discountAmount: DiscountAmountAndroid? = null, + /** + * Percentage discount (e.g., 33 for 33% off) + * Only returned for percentage-based discounts + */ + val percentageDiscount: Int? = null +) { + + companion object { + fun fromJson(json: Map): DiscountDisplayInfoAndroid { + return DiscountDisplayInfoAndroid( + discountAmount = (json["discountAmount"] as Map?)?.let { DiscountAmountAndroid.fromJson(it) }, + percentageDiscount = (json["percentageDiscount"] as Number?)?.toInt(), + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "DiscountDisplayInfoAndroid", + "discountAmount" to discountAmount?.toJson(), + "percentageDiscount" to percentageDiscount, + ) +} + public data class DiscountIOS( val identifier: String, val localizedPrice: String? = null, @@ -913,6 +977,37 @@ public data class FetchProductsResultProducts(val value: List?) : Fetch public data class FetchProductsResultSubscriptions(val value: List?) : FetchProductsResult +/** + * Limited quantity information for one-time purchase offers (Android) + * Available in Google Play Billing Library 7.0+ + */ +public data class LimitedQuantityInfoAndroid( + /** + * Maximum quantity a user can purchase + */ + val maximumQuantity: Int, + /** + * Remaining quantity the user can still purchase + */ + val remainingQuantity: Int +) { + + companion object { + fun fromJson(json: Map): LimitedQuantityInfoAndroid { + return LimitedQuantityInfoAndroid( + maximumQuantity = (json["maximumQuantity"] as Number).toInt(), + remainingQuantity = (json["remainingQuantity"] as Number).toInt(), + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "LimitedQuantityInfoAndroid", + "maximumQuantity" to maximumQuantity, + "remainingQuantity" to remainingQuantity, + ) +} + /** * Pre-order details for one-time purchase products (Android) * Available in Google Play Billing Library 8.1.0+ @@ -1005,7 +1100,11 @@ public data class ProductAndroid( override val displayPrice: String, override val id: String, val nameAndroid: String, - val oneTimePurchaseOfferDetailsAndroid: ProductAndroidOneTimePurchaseOfferDetail? = null, + /** + * One-time purchase offer details including discounts (Android) + * Returns all eligible offers. Available in Google Play Billing Library 7.0+ + */ + val oneTimePurchaseOfferDetailsAndroid: List? = null, override val platform: IapPlatform = IapPlatform.Android, override val price: Double? = null, val subscriptionOfferDetailsAndroid: List? = null, @@ -1023,7 +1122,7 @@ public data class ProductAndroid( displayPrice = json["displayPrice"] as String, id = json["id"] as String, nameAndroid = json["nameAndroid"] as String, - oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as Map?)?.let { ProductAndroidOneTimePurchaseOfferDetail.fromJson(it) }, + oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as List<*>?)?.map { ProductAndroidOneTimePurchaseOfferDetail.fromJson((it as Map)) }, platform = IapPlatform.fromJson(json["platform"] as String), price = (json["price"] as Number?)?.toDouble(), subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as List<*>?)?.map { ProductSubscriptionAndroidOfferDetails.fromJson((it as Map)) }, @@ -1042,7 +1141,7 @@ public data class ProductAndroid( "displayPrice" to displayPrice, "id" to id, "nameAndroid" to nameAndroid, - "oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseOfferDetailsAndroid?.toJson(), + "oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseOfferDetailsAndroid?.map { it.toJson() }, "platform" to platform.toJson(), "price" to price, "subscriptionOfferDetailsAndroid" to subscriptionOfferDetailsAndroid?.map { it.toJson() }, @@ -1051,34 +1150,88 @@ public data class ProductAndroid( ) } +/** + * One-time purchase offer details (Android) + * Available in Google Play Billing Library 7.0+ + */ public data class ProductAndroidOneTimePurchaseOfferDetail( + /** + * Discount display information + * Only available for discounted offers + */ + val discountDisplayInfo: DiscountDisplayInfoAndroid? = null, val formattedPrice: String, /** - * Pre-order details for products available for pre-order (Android) + * Full (non-discounted) price in micro-units + * Only available for discounted offers + */ + val fullPriceMicros: String? = null, + /** + * Limited quantity information + */ + val limitedQuantityInfo: LimitedQuantityInfoAndroid? = null, + /** + * Offer ID + */ + val offerId: String? = null, + /** + * List of offer tags + */ + val offerTags: List, + /** + * Offer token for use in BillingFlowParams when purchasing + */ + val offerToken: String, + /** + * Pre-order details for products available for pre-order * Available in Google Play Billing Library 8.1.0+ */ val preorderDetailsAndroid: PreorderDetailsAndroid? = null, val priceAmountMicros: String, - val priceCurrencyCode: String + val priceCurrencyCode: String, + /** + * Rental details for rental offers + */ + val rentalDetailsAndroid: RentalDetailsAndroid? = null, + /** + * Valid time window for the offer + */ + val validTimeWindow: ValidTimeWindowAndroid? = null ) { companion object { fun fromJson(json: Map): ProductAndroidOneTimePurchaseOfferDetail { return ProductAndroidOneTimePurchaseOfferDetail( + discountDisplayInfo = (json["discountDisplayInfo"] as Map?)?.let { DiscountDisplayInfoAndroid.fromJson(it) }, formattedPrice = json["formattedPrice"] as String, + fullPriceMicros = json["fullPriceMicros"] as String?, + limitedQuantityInfo = (json["limitedQuantityInfo"] as Map?)?.let { LimitedQuantityInfoAndroid.fromJson(it) }, + offerId = json["offerId"] as String?, + offerTags = (json["offerTags"] as List<*>).map { it as String }, + offerToken = json["offerToken"] as String, preorderDetailsAndroid = (json["preorderDetailsAndroid"] as Map?)?.let { PreorderDetailsAndroid.fromJson(it) }, priceAmountMicros = json["priceAmountMicros"] as String, priceCurrencyCode = json["priceCurrencyCode"] as String, + rentalDetailsAndroid = (json["rentalDetailsAndroid"] as Map?)?.let { RentalDetailsAndroid.fromJson(it) }, + validTimeWindow = (json["validTimeWindow"] as Map?)?.let { ValidTimeWindowAndroid.fromJson(it) }, ) } } fun toJson(): Map = mapOf( "__typename" to "ProductAndroidOneTimePurchaseOfferDetail", + "discountDisplayInfo" to discountDisplayInfo?.toJson(), "formattedPrice" to formattedPrice, + "fullPriceMicros" to fullPriceMicros, + "limitedQuantityInfo" to limitedQuantityInfo?.toJson(), + "offerId" to offerId, + "offerTags" to offerTags.map { it }, + "offerToken" to offerToken, "preorderDetailsAndroid" to preorderDetailsAndroid?.toJson(), "priceAmountMicros" to priceAmountMicros, "priceCurrencyCode" to priceCurrencyCode, + "rentalDetailsAndroid" to rentalDetailsAndroid?.toJson(), + "validTimeWindow" to validTimeWindow?.toJson(), ) } @@ -1150,7 +1303,11 @@ public data class ProductSubscriptionAndroid( override val displayPrice: String, override val id: String, val nameAndroid: String, - val oneTimePurchaseOfferDetailsAndroid: ProductAndroidOneTimePurchaseOfferDetail? = null, + /** + * One-time purchase offer details including discounts (Android) + * Returns all eligible offers. Available in Google Play Billing Library 7.0+ + */ + val oneTimePurchaseOfferDetailsAndroid: List? = null, override val platform: IapPlatform = IapPlatform.Android, override val price: Double? = null, val subscriptionOfferDetailsAndroid: List, @@ -1168,7 +1325,7 @@ public data class ProductSubscriptionAndroid( displayPrice = json["displayPrice"] as String, id = json["id"] as String, nameAndroid = json["nameAndroid"] as String, - oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as Map?)?.let { ProductAndroidOneTimePurchaseOfferDetail.fromJson(it) }, + oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as List<*>?)?.map { ProductAndroidOneTimePurchaseOfferDetail.fromJson((it as Map)) }, platform = IapPlatform.fromJson(json["platform"] as String), price = (json["price"] as Number?)?.toDouble(), subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as List<*>).map { ProductSubscriptionAndroidOfferDetails.fromJson((it as Map)) }, @@ -1187,7 +1344,7 @@ public data class ProductSubscriptionAndroid( "displayPrice" to displayPrice, "id" to id, "nameAndroid" to nameAndroid, - "oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseOfferDetailsAndroid?.toJson(), + "oneTimePurchaseOfferDetailsAndroid" to oneTimePurchaseOfferDetailsAndroid?.map { it.toJson() }, "platform" to platform.toJson(), "price" to price, "subscriptionOfferDetailsAndroid" to subscriptionOfferDetailsAndroid.map { it.toJson() }, @@ -1670,6 +1827,38 @@ public data class RenewalInfoIOS( ) } +/** + * Rental details for one-time purchase products that can be rented (Android) + * Available in Google Play Billing Library 7.0+ + */ +public data class RentalDetailsAndroid( + /** + * Rental expiration period in ISO 8601 format + * Time after rental period ends when user can still extend + */ + val rentalExpirationPeriod: String? = null, + /** + * Rental period in ISO 8601 format (e.g., P7D for 7 days) + */ + val rentalPeriod: String +) { + + companion object { + fun fromJson(json: Map): RentalDetailsAndroid { + return RentalDetailsAndroid( + rentalExpirationPeriod = json["rentalExpirationPeriod"] as String?, + rentalPeriod = json["rentalPeriod"] as String, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "RentalDetailsAndroid", + "rentalExpirationPeriod" to rentalExpirationPeriod, + "rentalPeriod" to rentalPeriod, + ) +} + public sealed interface RequestPurchaseResult public data class RequestPurchaseResultPurchase(val value: Purchase?) : RequestPurchaseResult @@ -1842,6 +2031,37 @@ public data class UserChoiceBillingDetails( ) } +/** + * Valid time window for when an offer is available (Android) + * Available in Google Play Billing Library 7.0+ + */ +public data class ValidTimeWindowAndroid( + /** + * End time in milliseconds since epoch + */ + val endTimeMillis: String, + /** + * Start time in milliseconds since epoch + */ + val startTimeMillis: String +) { + + companion object { + fun fromJson(json: Map): ValidTimeWindowAndroid { + return ValidTimeWindowAndroid( + endTimeMillis = json["endTimeMillis"] as String, + startTimeMillis = json["startTimeMillis"] as String, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "ValidTimeWindowAndroid", + "endTimeMillis" to endTimeMillis, + "startTimeMillis" to startTimeMillis, + ) +} + public data class VerifyPurchaseResultAndroid( val autoRenewing: Boolean, val betaProduct: Boolean, diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index 924839d0..1d17f520 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -324,6 +324,26 @@ public struct AppTransaction: Codable { public var signedDate: Double } +/// Discount amount details for one-time purchase offers (Android) +/// Available in Google Play Billing Library 7.0+ +public struct DiscountAmountAndroid: Codable { + /// Discount amount in micro-units (1,000,000 = 1 unit of currency) + public var discountAmountMicros: String + /// Formatted discount amount with currency sign (e.g., "$4.99") + public var formattedDiscountAmount: String +} + +/// Discount display information for one-time purchase offers (Android) +/// Available in Google Play Billing Library 7.0+ +public struct DiscountDisplayInfoAndroid: Codable { + /// Absolute discount amount details + /// Only returned for fixed amount discounts + public var discountAmount: DiscountAmountAndroid? + /// Percentage discount (e.g., 33 for 33% off) + /// Only returned for percentage-based discounts + public var percentageDiscount: Int? +} + public struct DiscountIOS: Codable { public var identifier: String public var localizedPrice: String? @@ -376,6 +396,15 @@ public enum FetchProductsResult { case subscriptions([ProductSubscription]?) } +/// Limited quantity information for one-time purchase offers (Android) +/// Available in Google Play Billing Library 7.0+ +public struct LimitedQuantityInfoAndroid: Codable { + /// Maximum quantity a user can purchase + public var maximumQuantity: Int + /// Remaining quantity the user can still purchase + public var remainingQuantity: Int +} + /// Pre-order details for one-time purchase products (Android) /// Available in Google Play Billing Library 8.1.0+ public struct PreorderDetailsAndroid: Codable { @@ -408,7 +437,9 @@ public struct ProductAndroid: Codable, ProductCommon { public var displayPrice: String public var id: String public var nameAndroid: String - public var oneTimePurchaseOfferDetailsAndroid: ProductAndroidOneTimePurchaseOfferDetail? + /// One-time purchase offer details including discounts (Android) + /// Returns all eligible offers. Available in Google Play Billing Library 7.0+ + public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]? public var platform: IapPlatform = .android public var price: Double? public var subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails]? @@ -416,13 +447,33 @@ public struct ProductAndroid: Codable, ProductCommon { public var type: ProductType = .inApp } +/// One-time purchase offer details (Android) +/// Available in Google Play Billing Library 7.0+ public struct ProductAndroidOneTimePurchaseOfferDetail: Codable { + /// Discount display information + /// Only available for discounted offers + public var discountDisplayInfo: DiscountDisplayInfoAndroid? public var formattedPrice: String - /// Pre-order details for products available for pre-order (Android) + /// Full (non-discounted) price in micro-units + /// Only available for discounted offers + public var fullPriceMicros: String? + /// Limited quantity information + public var limitedQuantityInfo: LimitedQuantityInfoAndroid? + /// Offer ID + public var offerId: String? + /// List of offer tags + public var offerTags: [String] + /// Offer token for use in BillingFlowParams when purchasing + public var offerToken: String + /// Pre-order details for products available for pre-order /// Available in Google Play Billing Library 8.1.0+ public var preorderDetailsAndroid: PreorderDetailsAndroid? public var priceAmountMicros: String public var priceCurrencyCode: String + /// Rental details for rental offers + public var rentalDetailsAndroid: RentalDetailsAndroid? + /// Valid time window for the offer + public var validTimeWindow: ValidTimeWindowAndroid? } public struct ProductIOS: Codable, ProductCommon { @@ -451,7 +502,9 @@ public struct ProductSubscriptionAndroid: Codable, ProductCommon { public var displayPrice: String public var id: String public var nameAndroid: String - public var oneTimePurchaseOfferDetailsAndroid: ProductAndroidOneTimePurchaseOfferDetail? + /// One-time purchase offer details including discounts (Android) + /// Returns all eligible offers. Available in Google Play Billing Library 7.0+ + public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]? public var platform: IapPlatform = .android public var price: Double? public var subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails] @@ -609,6 +662,16 @@ public struct RenewalInfoIOS: Codable { public var willAutoRenew: Bool } +/// Rental details for one-time purchase products that can be rented (Android) +/// Available in Google Play Billing Library 7.0+ +public struct RentalDetailsAndroid: Codable { + /// Rental expiration period in ISO 8601 format + /// Time after rental period ends when user can still extend + public var rentalExpirationPeriod: String? + /// Rental period in ISO 8601 format (e.g., P7D for 7 days) + public var rentalPeriod: String +} + public enum RequestPurchaseResult { case purchase(Purchase?) case purchases([Purchase]?) @@ -658,6 +721,15 @@ public struct UserChoiceBillingDetails: Codable { public var products: [String] } +/// Valid time window for when an offer is available (Android) +/// Available in Google Play Billing Library 7.0+ +public struct ValidTimeWindowAndroid: Codable { + /// End time in milliseconds since epoch + public var endTimeMillis: String + /// Start time in milliseconds since epoch + public var startTimeMillis: String +} + public struct VerifyPurchaseResultAndroid: Codable { public var autoRenewing: Bool public var betaProduct: Bool diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 390ebf41..e8417cdd 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -865,6 +865,72 @@ class AppTransaction { } } +/// Discount amount details for one-time purchase offers (Android) +/// Available in Google Play Billing Library 7.0+ +class DiscountAmountAndroid { + const DiscountAmountAndroid({ + /// Discount amount in micro-units (1,000,000 = 1 unit of currency) + required this.discountAmountMicros, + /// Formatted discount amount with currency sign (e.g., "$4.99") + required this.formattedDiscountAmount, + }); + + /// Discount amount in micro-units (1,000,000 = 1 unit of currency) + final String discountAmountMicros; + /// Formatted discount amount with currency sign (e.g., "$4.99") + final String formattedDiscountAmount; + + factory DiscountAmountAndroid.fromJson(Map json) { + return DiscountAmountAndroid( + discountAmountMicros: json['discountAmountMicros'] as String, + formattedDiscountAmount: json['formattedDiscountAmount'] as String, + ); + } + + Map toJson() { + return { + '__typename': 'DiscountAmountAndroid', + 'discountAmountMicros': discountAmountMicros, + 'formattedDiscountAmount': formattedDiscountAmount, + }; + } +} + +/// Discount display information for one-time purchase offers (Android) +/// Available in Google Play Billing Library 7.0+ +class DiscountDisplayInfoAndroid { + const DiscountDisplayInfoAndroid({ + /// Absolute discount amount details + /// Only returned for fixed amount discounts + this.discountAmount, + /// Percentage discount (e.g., 33 for 33% off) + /// Only returned for percentage-based discounts + this.percentageDiscount, + }); + + /// Absolute discount amount details + /// Only returned for fixed amount discounts + final DiscountAmountAndroid? discountAmount; + /// Percentage discount (e.g., 33 for 33% off) + /// Only returned for percentage-based discounts + final int? percentageDiscount; + + factory DiscountDisplayInfoAndroid.fromJson(Map json) { + return DiscountDisplayInfoAndroid( + discountAmount: json['discountAmount'] != null ? DiscountAmountAndroid.fromJson(json['discountAmount'] as Map) : null, + percentageDiscount: json['percentageDiscount'] as int?, + ); + } + + Map toJson() { + return { + '__typename': 'DiscountDisplayInfoAndroid', + 'discountAmount': discountAmount?.toJson(), + 'percentageDiscount': percentageDiscount, + }; + } +} + class DiscountIOS { const DiscountIOS({ required this.identifier, @@ -1069,6 +1135,37 @@ class FetchProductsResultSubscriptions extends FetchProductsResult { final List? value; } +/// Limited quantity information for one-time purchase offers (Android) +/// Available in Google Play Billing Library 7.0+ +class LimitedQuantityInfoAndroid { + const LimitedQuantityInfoAndroid({ + /// Maximum quantity a user can purchase + required this.maximumQuantity, + /// Remaining quantity the user can still purchase + required this.remainingQuantity, + }); + + /// Maximum quantity a user can purchase + final int maximumQuantity; + /// Remaining quantity the user can still purchase + final int remainingQuantity; + + factory LimitedQuantityInfoAndroid.fromJson(Map json) { + return LimitedQuantityInfoAndroid( + maximumQuantity: json['maximumQuantity'] as int, + remainingQuantity: json['remainingQuantity'] as int, + ); + } + + Map toJson() { + return { + '__typename': 'LimitedQuantityInfoAndroid', + 'maximumQuantity': maximumQuantity, + 'remainingQuantity': remainingQuantity, + }; + } +} + /// Pre-order details for one-time purchase products (Android) /// Available in Google Play Billing Library 8.1.0+ class PreorderDetailsAndroid { @@ -1175,6 +1272,8 @@ class ProductAndroid extends Product implements ProductCommon { required this.displayPrice, required this.id, required this.nameAndroid, + /// One-time purchase offer details including discounts (Android) + /// Returns all eligible offers. Available in Google Play Billing Library 7.0+ this.oneTimePurchaseOfferDetailsAndroid, this.platform = IapPlatform.Android, this.price, @@ -1190,7 +1289,9 @@ class ProductAndroid extends Product implements ProductCommon { final String displayPrice; final String id; final String nameAndroid; - final ProductAndroidOneTimePurchaseOfferDetail? oneTimePurchaseOfferDetailsAndroid; + /// One-time purchase offer details including discounts (Android) + /// Returns all eligible offers. Available in Google Play Billing Library 7.0+ + final List? oneTimePurchaseOfferDetailsAndroid; final IapPlatform platform; final double? price; final List? subscriptionOfferDetailsAndroid; @@ -1206,7 +1307,7 @@ class ProductAndroid extends Product implements ProductCommon { displayPrice: json['displayPrice'] as String, id: json['id'] as String, nameAndroid: json['nameAndroid'] as String, - oneTimePurchaseOfferDetailsAndroid: json['oneTimePurchaseOfferDetailsAndroid'] != null ? ProductAndroidOneTimePurchaseOfferDetail.fromJson(json['oneTimePurchaseOfferDetailsAndroid'] as Map) : null, + 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(), subscriptionOfferDetailsAndroid: (json['subscriptionOfferDetailsAndroid'] as List?) == null ? null : (json['subscriptionOfferDetailsAndroid'] as List?)!.map((e) => ProductSubscriptionAndroidOfferDetails.fromJson(e as Map)).toList(), @@ -1226,7 +1327,7 @@ class ProductAndroid extends Product implements ProductCommon { 'displayPrice': displayPrice, 'id': id, 'nameAndroid': nameAndroid, - 'oneTimePurchaseOfferDetailsAndroid': oneTimePurchaseOfferDetailsAndroid?.toJson(), + 'oneTimePurchaseOfferDetailsAndroid': oneTimePurchaseOfferDetailsAndroid == null ? null : oneTimePurchaseOfferDetailsAndroid!.map((e) => e.toJson()).toList(), 'platform': platform.toJson(), 'price': price, 'subscriptionOfferDetailsAndroid': subscriptionOfferDetailsAndroid == null ? null : subscriptionOfferDetailsAndroid!.map((e) => e.toJson()).toList(), @@ -1236,39 +1337,93 @@ class ProductAndroid extends Product implements ProductCommon { } } +/// One-time purchase offer details (Android) +/// Available in Google Play Billing Library 7.0+ class ProductAndroidOneTimePurchaseOfferDetail { const ProductAndroidOneTimePurchaseOfferDetail({ + /// Discount display information + /// Only available for discounted offers + this.discountDisplayInfo, required this.formattedPrice, - /// Pre-order details for products available for pre-order (Android) + /// Full (non-discounted) price in micro-units + /// Only available for discounted offers + this.fullPriceMicros, + /// Limited quantity information + this.limitedQuantityInfo, + /// Offer ID + this.offerId, + /// List of offer tags + required this.offerTags, + /// Offer token for use in BillingFlowParams when purchasing + required this.offerToken, + /// Pre-order details for products available for pre-order /// Available in Google Play Billing Library 8.1.0+ this.preorderDetailsAndroid, required this.priceAmountMicros, required this.priceCurrencyCode, + /// Rental details for rental offers + this.rentalDetailsAndroid, + /// Valid time window for the offer + this.validTimeWindow, }); + /// Discount display information + /// Only available for discounted offers + final DiscountDisplayInfoAndroid? discountDisplayInfo; final String formattedPrice; - /// Pre-order details for products available for pre-order (Android) + /// Full (non-discounted) price in micro-units + /// Only available for discounted offers + final String? fullPriceMicros; + /// Limited quantity information + final LimitedQuantityInfoAndroid? limitedQuantityInfo; + /// Offer ID + final String? offerId; + /// List of offer tags + final List offerTags; + /// Offer token for use in BillingFlowParams when purchasing + final String offerToken; + /// Pre-order details for products available for pre-order /// Available in Google Play Billing Library 8.1.0+ final PreorderDetailsAndroid? preorderDetailsAndroid; final String priceAmountMicros; final String priceCurrencyCode; + /// Rental details for rental offers + final RentalDetailsAndroid? rentalDetailsAndroid; + /// Valid time window for the offer + final ValidTimeWindowAndroid? validTimeWindow; factory ProductAndroidOneTimePurchaseOfferDetail.fromJson(Map json) { return ProductAndroidOneTimePurchaseOfferDetail( + discountDisplayInfo: json['discountDisplayInfo'] != null ? DiscountDisplayInfoAndroid.fromJson(json['discountDisplayInfo'] as Map) : null, formattedPrice: json['formattedPrice'] as String, + fullPriceMicros: json['fullPriceMicros'] as String?, + limitedQuantityInfo: json['limitedQuantityInfo'] != null ? LimitedQuantityInfoAndroid.fromJson(json['limitedQuantityInfo'] as Map) : null, + offerId: json['offerId'] as String?, + offerTags: (json['offerTags'] as List).map((e) => e as String).toList(), + offerToken: json['offerToken'] as String, preorderDetailsAndroid: json['preorderDetailsAndroid'] != null ? PreorderDetailsAndroid.fromJson(json['preorderDetailsAndroid'] as Map) : null, priceAmountMicros: json['priceAmountMicros'] as String, priceCurrencyCode: json['priceCurrencyCode'] as String, + rentalDetailsAndroid: json['rentalDetailsAndroid'] != null ? RentalDetailsAndroid.fromJson(json['rentalDetailsAndroid'] as Map) : null, + validTimeWindow: json['validTimeWindow'] != null ? ValidTimeWindowAndroid.fromJson(json['validTimeWindow'] as Map) : null, ); } Map toJson() { return { '__typename': 'ProductAndroidOneTimePurchaseOfferDetail', + 'discountDisplayInfo': discountDisplayInfo?.toJson(), 'formattedPrice': formattedPrice, + 'fullPriceMicros': fullPriceMicros, + 'limitedQuantityInfo': limitedQuantityInfo?.toJson(), + 'offerId': offerId, + 'offerTags': offerTags.map((e) => e).toList(), + 'offerToken': offerToken, 'preorderDetailsAndroid': preorderDetailsAndroid?.toJson(), 'priceAmountMicros': priceAmountMicros, 'priceCurrencyCode': priceCurrencyCode, + 'rentalDetailsAndroid': rentalDetailsAndroid?.toJson(), + 'validTimeWindow': validTimeWindow?.toJson(), }; } } @@ -1360,6 +1515,8 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC required this.displayPrice, required this.id, required this.nameAndroid, + /// One-time purchase offer details including discounts (Android) + /// Returns all eligible offers. Available in Google Play Billing Library 7.0+ this.oneTimePurchaseOfferDetailsAndroid, this.platform = IapPlatform.Android, this.price, @@ -1375,7 +1532,9 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC final String displayPrice; final String id; final String nameAndroid; - final ProductAndroidOneTimePurchaseOfferDetail? oneTimePurchaseOfferDetailsAndroid; + /// One-time purchase offer details including discounts (Android) + /// Returns all eligible offers. Available in Google Play Billing Library 7.0+ + final List? oneTimePurchaseOfferDetailsAndroid; final IapPlatform platform; final double? price; final List subscriptionOfferDetailsAndroid; @@ -1391,7 +1550,7 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC displayPrice: json['displayPrice'] as String, id: json['id'] as String, nameAndroid: json['nameAndroid'] as String, - oneTimePurchaseOfferDetailsAndroid: json['oneTimePurchaseOfferDetailsAndroid'] != null ? ProductAndroidOneTimePurchaseOfferDetail.fromJson(json['oneTimePurchaseOfferDetailsAndroid'] as Map) : null, + 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(), subscriptionOfferDetailsAndroid: (json['subscriptionOfferDetailsAndroid'] as List).map((e) => ProductSubscriptionAndroidOfferDetails.fromJson(e as Map)).toList(), @@ -1411,7 +1570,7 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC 'displayPrice': displayPrice, 'id': id, 'nameAndroid': nameAndroid, - 'oneTimePurchaseOfferDetailsAndroid': oneTimePurchaseOfferDetailsAndroid?.toJson(), + 'oneTimePurchaseOfferDetailsAndroid': oneTimePurchaseOfferDetailsAndroid == null ? null : oneTimePurchaseOfferDetailsAndroid!.map((e) => e.toJson()).toList(), 'platform': platform.toJson(), 'price': price, 'subscriptionOfferDetailsAndroid': subscriptionOfferDetailsAndroid.map((e) => e.toJson()).toList(), @@ -2022,6 +2181,39 @@ class RenewalInfoIOS { } } +/// Rental details for one-time purchase products that can be rented (Android) +/// Available in Google Play Billing Library 7.0+ +class RentalDetailsAndroid { + const RentalDetailsAndroid({ + /// Rental expiration period in ISO 8601 format + /// Time after rental period ends when user can still extend + this.rentalExpirationPeriod, + /// Rental period in ISO 8601 format (e.g., P7D for 7 days) + required this.rentalPeriod, + }); + + /// Rental expiration period in ISO 8601 format + /// Time after rental period ends when user can still extend + final String? rentalExpirationPeriod; + /// Rental period in ISO 8601 format (e.g., P7D for 7 days) + final String rentalPeriod; + + factory RentalDetailsAndroid.fromJson(Map json) { + return RentalDetailsAndroid( + rentalExpirationPeriod: json['rentalExpirationPeriod'] as String?, + rentalPeriod: json['rentalPeriod'] as String, + ); + } + + Map toJson() { + return { + '__typename': 'RentalDetailsAndroid', + 'rentalExpirationPeriod': rentalExpirationPeriod, + 'rentalPeriod': rentalPeriod, + }; + } +} + abstract class RequestPurchaseResult { const RequestPurchaseResult(); } @@ -2228,6 +2420,37 @@ class UserChoiceBillingDetails { } } +/// Valid time window for when an offer is available (Android) +/// Available in Google Play Billing Library 7.0+ +class ValidTimeWindowAndroid { + const ValidTimeWindowAndroid({ + /// End time in milliseconds since epoch + required this.endTimeMillis, + /// Start time in milliseconds since epoch + required this.startTimeMillis, + }); + + /// End time in milliseconds since epoch + final String endTimeMillis; + /// Start time in milliseconds since epoch + final String startTimeMillis; + + factory ValidTimeWindowAndroid.fromJson(Map json) { + return ValidTimeWindowAndroid( + endTimeMillis: json['endTimeMillis'] as String, + startTimeMillis: json['startTimeMillis'] as String, + ); + } + + Map toJson() { + return { + '__typename': 'ValidTimeWindowAndroid', + 'endTimeMillis': endTimeMillis, + 'startTimeMillis': startTimeMillis, + }; + } +} + class VerifyPurchaseResultAndroid extends VerifyPurchaseResult { const VerifyPurchaseResultAndroid({ required this.autoRenewing, diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 8992b97d..ab5709ef 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -72,6 +72,34 @@ export interface DeepLinkOptions { skuAndroid?: (string | null); } +/** + * Discount amount details for one-time purchase offers (Android) + * Available in Google Play Billing Library 7.0+ + */ +export interface DiscountAmountAndroid { + /** Discount amount in micro-units (1,000,000 = 1 unit of currency) */ + discountAmountMicros: string; + /** Formatted discount amount with currency sign (e.g., "$4.99") */ + formattedDiscountAmount: string; +} + +/** + * Discount display information for one-time purchase offers (Android) + * Available in Google Play Billing Library 7.0+ + */ +export interface DiscountDisplayInfoAndroid { + /** + * Absolute discount amount details + * Only returned for fixed amount discounts + */ + discountAmount?: (DiscountAmountAndroid | null); + /** + * Percentage discount (e.g., 33 for 33% off) + * Only returned for percentage-based discounts + */ + percentageDiscount?: (number | null); +} + export interface DiscountIOS { identifier: string; localizedPrice?: (string | null); @@ -194,6 +222,17 @@ export interface InitConnectionConfig { alternativeBillingModeAndroid?: (AlternativeBillingModeAndroid | null); } +/** + * Limited quantity information for one-time purchase offers (Android) + * Available in Google Play Billing Library 7.0+ + */ +export interface LimitedQuantityInfoAndroid { + /** Maximum quantity a user can purchase */ + maximumQuantity: number; + /** Remaining quantity the user can still purchase */ + remainingQuantity: number; +} + export interface Mutation { /** Acknowledge a non-consumable purchase or subscription */ acknowledgePurchaseAndroid: Promise; @@ -350,7 +389,11 @@ export interface ProductAndroid extends ProductCommon { displayPrice: string; id: string; nameAndroid: string; - oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail | null); + /** + * One-time purchase offer details including discounts (Android) + * Returns all eligible offers. Available in Google Play Billing Library 7.0+ + */ + oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); platform: 'android'; price?: (number | null); subscriptionOfferDetailsAndroid?: (ProductSubscriptionAndroidOfferDetails[] | null); @@ -358,15 +401,41 @@ export interface ProductAndroid extends ProductCommon { type: 'in-app'; } +/** + * One-time purchase offer details (Android) + * Available in Google Play Billing Library 7.0+ + */ export interface ProductAndroidOneTimePurchaseOfferDetail { + /** + * Discount display information + * Only available for discounted offers + */ + discountDisplayInfo?: (DiscountDisplayInfoAndroid | null); formattedPrice: string; /** - * Pre-order details for products available for pre-order (Android) + * Full (non-discounted) price in micro-units + * Only available for discounted offers + */ + fullPriceMicros?: (string | null); + /** Limited quantity information */ + limitedQuantityInfo?: (LimitedQuantityInfoAndroid | null); + /** Offer ID */ + offerId?: (string | null); + /** List of offer tags */ + offerTags: string[]; + /** Offer token for use in BillingFlowParams when purchasing */ + offerToken: string; + /** + * Pre-order details for products available for pre-order * Available in Google Play Billing Library 8.1.0+ */ preorderDetailsAndroid?: (PreorderDetailsAndroid | null); priceAmountMicros: string; priceCurrencyCode: string; + /** Rental details for rental offers */ + rentalDetailsAndroid?: (RentalDetailsAndroid | null); + /** Valid time window for the offer */ + validTimeWindow?: (ValidTimeWindowAndroid | null); } export interface ProductCommon { @@ -419,7 +488,11 @@ export interface ProductSubscriptionAndroid extends ProductCommon { displayPrice: string; id: string; nameAndroid: string; - oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail | null); + /** + * One-time purchase offer details including discounts (Android) + * Returns all eligible offers. Available in Google Play Billing Library 7.0+ + */ + oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null); platform: 'android'; price?: (number | null); subscriptionOfferDetailsAndroid: ProductSubscriptionAndroidOfferDetails[]; @@ -708,6 +781,20 @@ export interface RenewalInfoIOS { willAutoRenew: boolean; } +/** + * Rental details for one-time purchase products that can be rented (Android) + * Available in Google Play Billing Library 7.0+ + */ +export interface RentalDetailsAndroid { + /** + * Rental expiration period in ISO 8601 format + * Time after rental period ends when user can still extend + */ + rentalExpirationPeriod?: (string | null); + /** Rental period in ISO 8601 format (e.g., P7D for 7 days) */ + rentalPeriod: string; +} + export interface RequestPurchaseAndroidProps { /** Personalized offer flag */ isOfferPersonalized?: (boolean | null); @@ -881,6 +968,17 @@ export interface UserChoiceBillingDetails { products: string[]; } +/** + * Valid time window for when an offer is available (Android) + * Available in Google Play Billing Library 7.0+ + */ +export interface ValidTimeWindowAndroid { + /** End time in milliseconds since epoch */ + endTimeMillis: string; + /** Start time in milliseconds since epoch */ + startTimeMillis: string; +} + export interface VerifyPurchaseAndroidOptions { accessToken: string; isSub?: (boolean | null); diff --git a/packages/gql/src/type-android.graphql b/packages/gql/src/type-android.graphql index c81074d3..83bd1330 100644 --- a/packages/gql/src/type-android.graphql +++ b/packages/gql/src/type-android.graphql @@ -31,15 +31,131 @@ type PreorderDetailsAndroid { preorderReleaseTimeMillis: String! } +""" +Rental details for one-time purchase products that can be rented (Android) +Available in Google Play Billing Library 7.0+ +""" +type RentalDetailsAndroid { + """ + Rental period in ISO 8601 format (e.g., P7D for 7 days) + """ + rentalPeriod: String! + """ + Rental expiration period in ISO 8601 format + Time after rental period ends when user can still extend + """ + rentalExpirationPeriod: String +} + +""" +Valid time window for when an offer is available (Android) +Available in Google Play Billing Library 7.0+ +""" +type ValidTimeWindowAndroid { + """ + Start time in milliseconds since epoch + """ + startTimeMillis: String! + """ + End time in milliseconds since epoch + """ + endTimeMillis: String! +} + +""" +Limited quantity information for one-time purchase offers (Android) +Available in Google Play Billing Library 7.0+ +""" +type LimitedQuantityInfoAndroid { + """ + Maximum quantity a user can purchase + """ + maximumQuantity: Int! + """ + Remaining quantity the user can still purchase + """ + remainingQuantity: Int! +} + +""" +Discount amount details for one-time purchase offers (Android) +Available in Google Play Billing Library 7.0+ +""" +type DiscountAmountAndroid { + """ + Discount amount in micro-units (1,000,000 = 1 unit of currency) + """ + discountAmountMicros: String! + """ + Formatted discount amount with currency sign (e.g., "$4.99") + """ + formattedDiscountAmount: String! +} + +""" +Discount display information for one-time purchase offers (Android) +Available in Google Play Billing Library 7.0+ +""" +type DiscountDisplayInfoAndroid { + """ + Percentage discount (e.g., 33 for 33% off) + Only returned for percentage-based discounts + """ + percentageDiscount: Int + """ + Absolute discount amount details + Only returned for fixed amount discounts + """ + discountAmount: DiscountAmountAndroid +} + +""" +One-time purchase offer details (Android) +Available in Google Play Billing Library 7.0+ +""" type ProductAndroidOneTimePurchaseOfferDetail { + """ + Offer ID + """ + offerId: String + """ + Offer token for use in BillingFlowParams when purchasing + """ + offerToken: String! + """ + List of offer tags + """ + offerTags: [String!]! priceCurrencyCode: String! formattedPrice: String! priceAmountMicros: String! """ - Pre-order details for products available for pre-order (Android) + Full (non-discounted) price in micro-units + Only available for discounted offers + """ + fullPriceMicros: String + """ + Discount display information + Only available for discounted offers + """ + discountDisplayInfo: DiscountDisplayInfoAndroid + """ + Valid time window for the offer + """ + validTimeWindow: ValidTimeWindowAndroid + """ + Limited quantity information + """ + limitedQuantityInfo: LimitedQuantityInfoAndroid + """ + Pre-order details for products available for pre-order Available in Google Play Billing Library 8.1.0+ """ preorderDetailsAndroid: PreorderDetailsAndroid + """ + Rental details for rental offers + """ + rentalDetailsAndroid: RentalDetailsAndroid } type ProductSubscriptionAndroidOfferDetails { @@ -65,7 +181,11 @@ type ProductAndroid implements ProductCommon { # Android-specific nameAndroid: String! - oneTimePurchaseOfferDetailsAndroid: ProductAndroidOneTimePurchaseOfferDetail + """ + One-time purchase offer details including discounts (Android) + Returns all eligible offers. Available in Google Play Billing Library 7.0+ + """ + oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail!] subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails!] } @@ -84,7 +204,11 @@ type ProductSubscriptionAndroid implements ProductCommon { # Android-specific nameAndroid: String! - oneTimePurchaseOfferDetailsAndroid: ProductAndroidOneTimePurchaseOfferDetail + """ + One-time purchase offer details including discounts (Android) + Returns all eligible offers. Available in Google Play Billing Library 7.0+ + """ + oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail!] subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails!]! }