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:
+
+ - Go to Google Play Console > Monetization > Products
+ - Select your one-time product or create a new one
+ -
+ In the product details, look for the Offers section
+
+ -
+ Click Add offer to create a promotional offer
+
+ -
+ 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)
+
+
+
+ -
+ Save and publish your changes - it may take a few hours for changes
+ to propagate
+
+
+
+
+
+ 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!]!
}