diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd749f7b6..f7828d735 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,11 +131,15 @@ jobs: with: bun-version: 1.3.13 - - name: Install GQL audit dependencies + - name: Install parity audit dependencies run: | - # Retry bun install up to 3 times to handle transient registry errors + # The root parity scripts use root devDependencies (for example, + # TypeScript) as well as GQL workspace tooling. Install both scopes + # so Node can resolve them without installing unrelated workspaces. for i in 1 2 3; do - bun install --frozen-lockfile --filter @hyodotdev/openiap-gql && break + bun install --frozen-lockfile \ + --filter @hyodotdev/openiap \ + --filter @hyodotdev/openiap-gql && break [ $i -eq 3 ] && exit 1 echo "Attempt $i failed. Retrying..." sleep 5 diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md index cbb69ed46..3aa203bc3 100644 --- a/knowledge/_claude-context/context.md +++ b/knowledge/_claude-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated for Claude Code** -> Last updated: 2026-07-24T15:33:00.652Z +> Last updated: 2026-07-24T15:52:26.154Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -950,6 +950,27 @@ If it fails for Godot GDAP dependency drift, run `./libraries/godot-iap/scripts/write-gdap.sh` and commit the regenerated `libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap`. +### Generated payload preservation + +Generated payload types are additive contracts. Handwritten native and framework +bridges must preserve every canonical field rather than reconstructing +`Purchase`, `ActiveSubscription`, `RenewalInfoIOS`, or verification results from +local allowlists. Prefer the generated `toJson` / `fromJson` or canonical +serializer, recursively normalize platform dictionaries and `NSNull`, and add +only documented transport-specific fields around that generated payload. + +Map canonical fields from their same-named native source before applying a +compatibility fallback. In particular, an orderless Google Play purchase keeps +`transactionId` null instead of copying `purchaseToken`, while alternative-store +deferred plan changes remain active purchases and expose +`pendingPurchaseUpdateAndroid` plus the current plan. Listener diagnostics must +never include raw purchase payloads, receipts, or tokens. + +`bun run audit:parity` compares generated payload fields with the handwritten +bridges and exercises source-first mappings and round trips. When a generated +payload field or bridge changes, update the real platform mapping and a focused +regression fixture before extending the audit expectation. + ### The bug pattern A symptom like "interface exists in `types.dart` / `types.ts` / `Types.kt` but calling it does nothing / throws" means one or more of these layers is missing: diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md index 6dac5ad92..3ebd61208 100644 --- a/knowledge/internal/04-platform-packages.md +++ b/knowledge/internal/04-platform-packages.md @@ -166,6 +166,27 @@ If it fails for Godot GDAP dependency drift, run `./libraries/godot-iap/scripts/write-gdap.sh` and commit the regenerated `libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap`. +### Generated payload preservation + +Generated payload types are additive contracts. Handwritten native and framework +bridges must preserve every canonical field rather than reconstructing +`Purchase`, `ActiveSubscription`, `RenewalInfoIOS`, or verification results from +local allowlists. Prefer the generated `toJson` / `fromJson` or canonical +serializer, recursively normalize platform dictionaries and `NSNull`, and add +only documented transport-specific fields around that generated payload. + +Map canonical fields from their same-named native source before applying a +compatibility fallback. In particular, an orderless Google Play purchase keeps +`transactionId` null instead of copying `purchaseToken`, while alternative-store +deferred plan changes remain active purchases and expose +`pendingPurchaseUpdateAndroid` plus the current plan. Listener diagnostics must +never include raw purchase payloads, receipts, or tokens. + +`bun run audit:parity` compares generated payload fields with the handwritten +bridges and exercises source-first mappings and round trips. When a generated +payload field or bridge changes, update the real platform mapping and a focused +regression fixture before extending the audit expectation. + ### The bug pattern A symptom like "interface exists in `types.dart` / `types.ts` / `Types.kt` but calling it does nothing / throws" means one or more of these layers is missing: diff --git a/libraries/expo-iap/ios/ExpoIapHelper.swift b/libraries/expo-iap/ios/ExpoIapHelper.swift index 0b37b5213..bcb652f49 100644 --- a/libraries/expo-iap/ios/ExpoIapHelper.swift +++ b/libraries/expo-iap/ios/ExpoIapHelper.swift @@ -67,7 +67,7 @@ enum ExpoIapHelper { static func parseProductQueryType(_ rawValue: String?) -> ProductQueryType { guard let raw = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { - return .all + return .inApp } switch raw.lowercased() { case "inapp", ProductQueryType.inApp.rawValue: @@ -77,7 +77,7 @@ enum ExpoIapHelper { case ProductQueryType.all.rawValue: return .all default: - return .all + return .inApp } } diff --git a/libraries/expo-iap/ios/onside/OnsideIapModule.swift b/libraries/expo-iap/ios/onside/OnsideIapModule.swift index 38bce3f82..95fb55967 100644 --- a/libraries/expo-iap/ios/onside/OnsideIapModule.swift +++ b/libraries/expo-iap/ios/onside/OnsideIapModule.swift @@ -175,11 +175,21 @@ public final class ExpoIapOnsideModule: Module { throw OnsideBridgeError.productNotFound(response.invalidProductIdentifiers.joined(separator: ", ")) } + let matchingProducts = response.products.filter { product in + switch request.type ?? .inApp { + case .subs: + return product.subscriptionPeriod != nil + case .inApp: + return product.subscriptionPeriod == nil + case .all: + return true + } + } let payload: [[String: Any]] = try await MainActor.run { - for p in response.products { + for p in matchingProducts { productCache[p.productIdentifier] = p } - return try response.products.map { try serializeProduct($0) } + return try matchingProducts.map { try serializeProduct($0) } } ExpoIapLog.result("fetchProductsOnside", value: payload) return payload @@ -458,9 +468,29 @@ public final class ExpoIapOnsideModule: Module { dictionary["displayPrice"] = formattedPrice dictionary["currency"] = product.price.currencyCode dictionary["price"] = priceNumber - dictionary["type"] = "in-app" - dictionary["typeIOS"] = "non-consumable" + let subscriptionPeriod = product.subscriptionPeriod.map { + subscriptionPeriodComponentsIOS($0) + } + let isSubscription = subscriptionPeriod != nil + dictionary["type"] = isSubscription ? "subs" : "in-app" + dictionary["typeIOS"] = isSubscription ? "auto-renewable-subscription" : "non-consumable" dictionary["isFamilyShareableIOS"] = false + dictionary["subscriptionGroupIdIOS"] = product.subscriptionGroupIdentifier + if let subscriptionPeriod { + dictionary["subscriptionPeriodNumberIOS"] = String(subscriptionPeriod.value) + dictionary["subscriptionPeriodUnitIOS"] = subscriptionPeriod.unit + } + if let introductoryPrice = product.introductoryPrice { + let introductoryPeriod = subscriptionPeriodComponentsIOS(introductoryPrice.period) + dictionary["introductoryPriceAsAmountIOS"] = String(introductoryPrice.price.value) + dictionary["introductoryPriceIOS"] = formatPriceIOS(introductoryPrice.price) + dictionary["introductoryPriceNumberOfPeriodsIOS"] = String(introductoryPeriod.value) + dictionary["introductoryPricePaymentModeIOS"] = + introductoryPricePaymentModeIOS(for: introductoryPrice).rawValue + dictionary["introductoryPriceSubscriptionPeriodIOS"] = introductoryPeriod.unit + } else if isSubscription { + dictionary["introductoryPricePaymentModeIOS"] = PaymentModeIOS.empty.rawValue + } // Avoid JSONEncoder on non-Encodable SDK type: build JSON string from known fields dictionary["jsonRepresentationIOS"] = try makeProductJSONRepresentation(from: product) dictionary["debugDescription"] = product.description @@ -474,7 +504,17 @@ public final class ExpoIapOnsideModule: Module { dictionary["transactionId"] = transaction.transactionIdentifier ?? "" dictionary["productId"] = transaction.payment.product.productIdentifier dictionary["platform"] = "ios" + // Onside is an alternative iOS marketplace and does not yet have a + // dedicated IapStore enum value. Preserve the required store + // discriminator without reporting the purchase as App Store traffic. + dictionary["store"] = "unknown" + if product.subscriptionPeriod == nil { + dictionary["currentPlanId"] = NSNull() + } else { + dictionary["currentPlanId"] = product.productIdentifier + } dictionary["quantity"] = 1 + dictionary["quantityIOS"] = 1 dictionary["isAutoRenewing"] = false dictionary["purchaseState"] = mapPurchaseState(transaction.transactionState) let txDate = fallbackTransactionDate(for: transaction) @@ -485,9 +525,15 @@ public final class ExpoIapOnsideModule: Module { currencyFormatter.currencyCode = product.price.currencyCode dictionary["currencySymbolIOS"] = currencyFormatter.currencySymbol ?? "" + dictionary["countryCodeIOS"] = transaction.storefront.countryCode dictionary["storefrontCountryCodeIOS"] = transaction.storefront.countryCode + dictionary["subscriptionGroupIdIOS"] = product.subscriptionGroupIdentifier + dictionary["originalTransactionIdentifierIOS"] = + transaction.originalTransactionIdentifier dictionary["purchaseToken"] = nil - dictionary["environmentIOS"] = transaction.storefront.id + // Onside exposes storefront identity, not StoreKit's Sandbox/Production + // environment. Do not mislabel a marketplace/storefront identifier. + dictionary["environmentIOS"] = NSNull() if let error = transaction.error { dictionary["reasonIOS"] = error.localizedDescription } @@ -501,7 +547,10 @@ public final class ExpoIapOnsideModule: Module { priceFormatter.currencyCode = product.price.currencyCode let priceNumber = makePriceNumber(from: product) let formattedPrice = priceFormatter.string(from: priceNumber) ?? "\(product.price.value)" - let jsonObject: [String: Any] = [ + let subscriptionPeriod = product.subscriptionPeriod.map { + subscriptionPeriodComponentsIOS($0) + } + var jsonObject: [String: Any] = [ "id": product.productIdentifier, "title": product.localizedTitle, "description": product.localizedDescription, @@ -512,8 +561,17 @@ public final class ExpoIapOnsideModule: Module { ], "isFamilyShareable": false, "platform": "ios", - "type": "in-app", + "type": subscriptionPeriod == nil ? "in-app" : "subs", ] + if let subscriptionGroupIdentifier = product.subscriptionGroupIdentifier { + jsonObject["subscriptionGroupIdentifier"] = subscriptionGroupIdentifier + } + if let subscriptionPeriod { + jsonObject["subscriptionPeriod"] = [ + "value": subscriptionPeriod.value, + "unit": subscriptionPeriod.unit, + ] + } let data = try JSONSerialization.data(withJSONObject: jsonObject, options: []) guard let json = String(data: data, encoding: .utf8) else { throw OnsideBridgeError.queueError("Unable to encode JSON string") @@ -525,6 +583,43 @@ public final class ExpoIapOnsideModule: Module { NSDecimalNumber(string: String(product.price.value)) } + private func formatPriceIOS(_ price: OnsidePrice) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = price.currencyCode + let number = NSDecimalNumber(string: String(price.value)) + return formatter.string(from: number) ?? "\(price.value)" + } + + private func introductoryPricePaymentModeIOS( + for offer: OnsidePricePeriod + ) -> PaymentModeIOS { + if offer.price.value == 0 { + return .freeTrial + } + + // OnsideKit exposes only price and period for introductory offers, so + // paid offers cannot be distinguished as pay-as-you-go or pay-up-front. + return .empty + } + + private func subscriptionPeriodComponentsIOS( + _ period: OnsidePeriod + ) -> (value: Int, unit: String) { + switch period { + case .day(let value): + return (Int(value), "day") + case .week(let value): + return (Int(value), "week") + case .month(let value): + return (Int(value), "month") + case .year(let value): + return (Int(value), "year") + @unknown default: + return (0, "empty") + } + } + private func fallbackTransactionDate(for transaction: OnsidePaymentTransaction) -> Date { let cacheKey = transaction.transactionIdentifier ?? transaction.originalTransactionIdentifier @@ -553,9 +648,9 @@ public final class ExpoIapOnsideModule: Module { case .purchased: return "purchased" case .restored: - return "restored" + return "purchased" case .failed: - return "failed" + return "unknown" case .purchasing: return "pending" @unknown default: diff --git a/libraries/expo-iap/src/ExpoIapModule.ts b/libraries/expo-iap/src/ExpoIapModule.ts index 468e60bde..41c70465f 100644 --- a/libraries/expo-iap/src/ExpoIapModule.ts +++ b/libraries/expo-iap/src/ExpoIapModule.ts @@ -6,7 +6,6 @@ type NativeIapModuleName = 'ExpoIapVega' | 'ExpoIapOnside' | 'ExpoIap'; const ONSIDE_MARKETPLACE_ID = 'com.onside.marketplace-app'; let cached: {module: any; name: NativeIapModuleName} | null = null; -let expoIapFallback: any | null | undefined; let onsideModuleUnavailable = false; function getResolved(): {module: any; name: NativeIapModuleName} { @@ -28,9 +27,7 @@ function getResolved(): {module: any; name: NativeIapModuleName} { return 'ExpoIapVega'; } - return shouldUseOnsideModule() && !onsideModuleUnavailable - ? 'ExpoIapOnside' - : 'ExpoIap'; + return shouldUseOnsideModule() ? 'ExpoIapOnside' : 'ExpoIap'; } function resolveNativeModule(): { @@ -49,6 +46,12 @@ function getResolved(): {module: any; name: NativeIapModuleName} { } if (shouldUseOnsideModule()) { + if (onsideModuleUnavailable) { + throw new UnavailabilityError( + 'expo-iap', + 'The Onside marketplace build does not contain ExpoIapOnside. Rebuild with ios.onside.enabled instead of routing purchases through Apple StoreKit.', + ); + } try { return { module: requireNativeModule('ExpoIapOnside'), @@ -59,6 +62,10 @@ function getResolved(): {module: any; name: NativeIapModuleName} { throw error; } onsideModuleUnavailable = true; + throw new UnavailabilityError( + 'expo-iap', + 'The Onside marketplace build does not contain ExpoIapOnside. Rebuild with ios.onside.enabled instead of routing purchases through Apple StoreKit.', + ); } } @@ -84,24 +91,6 @@ function isMissingModuleError(error: unknown, moduleName: string): boolean { return false; } -function getExpoIapFallbackModule(): any | null { - if (expoIapFallback !== undefined) { - return expoIapFallback; - } - - try { - expoIapFallback = requireNativeModule('ExpoIap'); - } catch (error) { - if (isMissingModuleError(error, 'ExpoIap')) { - expoIapFallback = null; - } else { - throw error; - } - } - - return expoIapFallback; -} - export const NATIVE_ERROR_CODES: Record = new Proxy( {} as Record, { @@ -138,6 +127,11 @@ export default new Proxy({} as any, { return value; } - return getExpoIapFallbackModule()?.[prop]; + return () => { + throw new UnavailabilityError( + 'expo-iap', + `The Onside marketplace does not support ${String(prop)}. The call was not routed through Apple StoreKit.`, + ); + }; }, }); diff --git a/libraries/expo-iap/src/__tests__/ExpoIapModule.test.ts b/libraries/expo-iap/src/__tests__/ExpoIapModule.test.ts index 532f710e4..dcada385d 100644 --- a/libraries/expo-iap/src/__tests__/ExpoIapModule.test.ts +++ b/libraries/expo-iap/src/__tests__/ExpoIapModule.test.ts @@ -98,17 +98,11 @@ describe('ExpoIapModule proxy', () => { expect(requireNativeModule).toHaveBeenCalledWith('ExpoIapOnside'); }); - it('does not repeatedly load a missing ExpoIapOnside module', () => { - const expoIapModule = { - ERROR_CODES: {}, - fetchProducts: jest.fn(), - verifyPurchase: jest.fn(), - }; + it('fails closed without repeatedly loading a missing ExpoIapOnside module', () => { const requireNativeModule = jest.fn((name: string) => { if (name === 'ExpoIapOnside') { throw new Error("Cannot find native module 'ExpoIapOnside'"); } - if (name === 'ExpoIap') return expoIapModule; throw new Error(`Cannot find native module '${name}'`); }); @@ -122,35 +116,28 @@ describe('ExpoIapModule proxy', () => { const ExpoIapModule = loadExpoIapModule(); - expect(ExpoIapModule.USING_ONSIDE_SDK).toBe(false); - expect(ExpoIapModule.fetchProducts).toBe(expoIapModule.fetchProducts); - expect(ExpoIapModule.verifyPurchase).toBe(expoIapModule.verifyPurchase); + expect(() => ExpoIapModule.USING_ONSIDE_SDK).toThrow(); + expect(() => ExpoIapModule.USING_ONSIDE_SDK).toThrow(); expect(requireNativeModule.mock.calls.map(([name]) => name)).toEqual([ 'ExpoIapOnside', - 'ExpoIap', ]); }); - it('falls back to ExpoIap for methods missing from ExpoIapOnside', () => { + it('fails closed for methods missing from ExpoIapOnside', () => { const onsideModule = { ERROR_CODES: {}, requestPurchase: jest.fn(), }; - const expoIapModule = { - ERROR_CODES: {}, - getStorefront: jest.fn(), - verifyPurchase: jest.fn(), - }; + const requireNativeModule = jest.fn((name: string) => { + if (name === 'ExpoIapOnside') return onsideModule; + throw new Error(`Cannot find native module '${name}'`); + }); jest.doMock('../onside', () => ({ installedFromOnside: true, })); jest.doMock('expo-modules-core', () => ({ - requireNativeModule: jest.fn((name: string) => { - if (name === 'ExpoIapOnside') return onsideModule; - if (name === 'ExpoIap') return expoIapModule; - throw new Error(`Cannot find native module '${name}'`); - }), + requireNativeModule, UnavailabilityError: class UnavailabilityError extends Error {}, })); @@ -158,30 +145,8 @@ describe('ExpoIapModule proxy', () => { expect(ExpoIapModule.USING_ONSIDE_SDK).toBe(true); expect(ExpoIapModule.requestPurchase).toBe(onsideModule.requestPurchase); - expect(ExpoIapModule.verifyPurchase).toBe(expoIapModule.verifyPurchase); - expect(ExpoIapModule.getStorefront).toBe(expoIapModule.getStorefront); - }); - - it('surfaces non-missing ExpoIap fallback errors', () => { - const onsideModule = { - ERROR_CODES: {}, - requestPurchase: jest.fn(), - }; - - jest.doMock('../onside', () => ({ - installedFromOnside: true, - })); - jest.doMock('expo-modules-core', () => ({ - requireNativeModule: jest.fn((name: string) => { - if (name === 'ExpoIapOnside') return onsideModule; - if (name === 'ExpoIap') throw new Error('native init failed'); - throw new Error(`Cannot find native module '${name}'`); - }), - UnavailabilityError: class UnavailabilityError extends Error {}, - })); - - const ExpoIapModule = loadExpoIapModule(); - - expect(() => ExpoIapModule.verifyPurchase).toThrow('native init failed'); + expect(() => ExpoIapModule.verifyPurchase()).toThrow(); + expect(() => ExpoIapModule.getActiveSubscriptions()).toThrow(); + expect(requireNativeModule).toHaveBeenCalledTimes(1); }); }); diff --git a/libraries/expo-iap/src/__tests__/canonical-key-presence.test.js b/libraries/expo-iap/src/__tests__/canonical-key-presence.test.js index cadceb1d4..d77083bc5 100644 --- a/libraries/expo-iap/src/__tests__/canonical-key-presence.test.js +++ b/libraries/expo-iap/src/__tests__/canonical-key-presence.test.js @@ -17,6 +17,17 @@ describe('native canonical-key presence contract', () => { expect(helper).toContain('request.removeValue(forKey: "ios")'); }); + it('fails closed to in-app for unrecognized iOS product query types', () => { + const helper = readExpoFile('ios/ExpoIapHelper.swift'); + const parser = helper.match( + /static func parseProductQueryType[\s\S]*?\n }\n\n static func decodeProductRequest/, + )?.[0]; + + expect(parser).toBeDefined(); + expect(parser).toContain('default:\n return .inApp'); + expect(parser).not.toContain('default:\n return .all'); + }); + it('does not let Onside fall through from an explicit apple key to ios', () => { const onside = readExpoFile('ios/onside/OnsideIapModule.swift'); diff --git a/libraries/expo-iap/src/__tests__/native-log-redaction.test.js b/libraries/expo-iap/src/__tests__/native-log-redaction.test.js index 46b402af1..cd73bed73 100644 --- a/libraries/expo-iap/src/__tests__/native-log-redaction.test.js +++ b/libraries/expo-iap/src/__tests__/native-log-redaction.test.js @@ -88,6 +88,67 @@ describe('native log redaction', () => { expect(onsideModule).toContain('code: .serviceError'); }); + it('keeps Onside purchases aligned with the canonical iOS payload', () => { + const onsideModule = readRepoFile('ios/onside/OnsideIapModule.swift'); + const iosHelper = readRepoFile('ios/ExpoIapHelper.swift'); + + expect(onsideModule).toContain('dictionary["store"] = "unknown"'); + expect(onsideModule).toMatch( + /if product\.subscriptionPeriod == nil \{\s+dictionary\["currentPlanId"\] = NSNull\(\)\s+\} else \{\s+dictionary\["currentPlanId"\] = product\.productIdentifier/, + ); + expect(onsideModule).toContain('dictionary["quantity"] = 1'); + expect(onsideModule).toContain('dictionary["quantityIOS"] = 1'); + expect(onsideModule).toContain( + 'dictionary["type"] = isSubscription ? "subs" : "in-app"', + ); + expect(onsideModule).toContain( + 'dictionary["typeIOS"] = isSubscription ? "auto-renewable-subscription" : "non-consumable"', + ); + expect(onsideModule).toContain( + 'dictionary["subscriptionPeriodUnitIOS"] = subscriptionPeriod.unit', + ); + expect(onsideModule).toContain( + 'introductoryPricePaymentModeIOS(for: introductoryPrice).rawValue', + ); + expect(onsideModule).toMatch( + /private func introductoryPricePaymentModeIOS\([\s\S]*?if offer\.price\.value == 0 \{\s+return \.freeTrial\s+\}[\s\S]*?return \.empty/, + ); + expect(onsideModule).toContain( + 'PaymentModeIOS.empty.rawValue', + ); + expect(onsideModule).toContain('private func formatPriceIOS('); + expect(onsideModule).toContain( + 'private func subscriptionPeriodComponentsIOS(', + ); + expect(onsideModule).not.toContain('private func formatPrice('); + expect(onsideModule).not.toContain( + 'private func subscriptionPeriodComponents(', + ); + expect(onsideModule).toMatch( + /switch request\.type \?\? \.inApp \{\s+case \.subs:\s+return product\.subscriptionPeriod != nil\s+case \.inApp:\s+return product\.subscriptionPeriod == nil\s+case \.all:\s+return true/, + ); + expect(onsideModule).toContain('dictionary["environmentIOS"] = NSNull()'); + expect(onsideModule).toContain( + 'dictionary["countryCodeIOS"] = transaction.storefront.countryCode', + ); + expect(onsideModule).toContain( + 'dictionary["subscriptionGroupIdIOS"] = product.subscriptionGroupIdentifier', + ); + expect(onsideModule).toMatch( + /dictionary\["originalTransactionIdentifierIOS"\] =\s+transaction\.originalTransactionIdentifier/, + ); + expect(onsideModule).not.toContain( + 'dictionary["environmentIOS"] = transaction.storefront.id', + ); + expect(onsideModule).toMatch(/case \.restored:\s+return "purchased"/); + expect(onsideModule).toMatch(/case \.failed:\s+return "unknown"/); + expect(onsideModule).not.toContain('return "restored"'); + expect(onsideModule).not.toContain('return "failed"'); + expect(iosHelper).toMatch( + /guard let raw = rawValue\?\.trimmingCharacters[\s\S]*?else \{\s+return \.inApp\s+\}/, + ); + }); + it('does not log raw IAPKit request bodies in the Apple core package', () => { const appleModule = readFileSync( resolve(rootDir, '../../packages/apple/Sources/OpenIapModule.swift'), diff --git a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts index c3962e7a7..e081d2a05 100644 --- a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts @@ -245,6 +245,8 @@ describe('Amazon Vega Expo adapter', () => { expect(result).toEqual([ expect.objectContaining({ + currentPlanId: null, + ids: ['coins_100'], productId: 'coins_100', purchaseToken: 'receipt-1', store: 'amazon', @@ -252,6 +254,8 @@ describe('Amazon Vega Expo adapter', () => { ]); expect(listener).toHaveBeenCalledWith( expect.objectContaining({ + currentPlanId: null, + ids: ['coins_100'], productId: 'coins_100', purchaseToken: 'receipt-1', }), @@ -808,6 +812,8 @@ describe('Amazon Vega Expo adapter', () => { }, }); const module = createExpoIapVegaModule(service); + const listener = jest.fn(); + module.addListener('purchase-updated', listener); await expect( module.requestPurchase({ @@ -816,11 +822,34 @@ describe('Amazon Vega Expo adapter', () => { }), ).resolves.toEqual([ expect.objectContaining({ + currentPlanId: 'premium_monthly', + ids: ['premium_monthly'], productId: 'premium_monthly', isAutoRenewing: true, autoRenewingAndroid: true, }), ]); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + currentPlanId: 'premium_monthly', + ids: ['premium_monthly'], + productId: 'premium_monthly', + }), + ); + }); + + it('preserves subscription plan identifiers in available purchases', async () => { + const service = createService(); + const module = createExpoIapVegaModule(service); + + await expect(module.getAvailableItems()).resolves.toEqual([ + expect.objectContaining({ + currentPlanId: 'premium_monthly', + ids: ['premium_monthly'], + productId: 'premium_monthly', + purchaseToken: 'sub-receipt', + }), + ]); }); it('loads all paginated Amazon purchase updates', async () => { @@ -1028,14 +1057,16 @@ describe('Amazon Vega Expo adapter', () => { expect(service.getProductData.mock.calls[1]?.[0].skus).toHaveLength(1); }); - it('excludes suspended purchases unless requested', async () => { + it('keeps deferred subscription changes active and exposes the upcoming plan', async () => { const service = createService(); service.getPurchaseUpdates.mockResolvedValue({ responseCode: 1, receiptList: [ { receiptId: 'deferred-sub', - sku: 'premium_monthly', + sku: 'premium', + termSku: 'premium_monthly', + deferredSku: 'premium_yearly', productType: 3, isDeferred: true, }, @@ -1043,16 +1074,46 @@ describe('Amazon Vega Expo adapter', () => { }); const module = createExpoIapVegaModule(service); - await expect(module.getAvailableItems()).resolves.toEqual([]); - - await expect( - module.getAvailableItems({includeSuspendedAndroid: true}), - ).resolves.toEqual([ + await expect(module.getAvailableItems()).resolves.toEqual([ expect.objectContaining({ id: 'deferred-sub', - isAutoRenewing: false, - isSuspendedAndroid: true, - purchaseState: 'pending', + productId: 'premium', + currentPlanId: 'premium_monthly', + isAutoRenewing: true, + isSuspendedAndroid: false, + pendingPurchaseUpdateAndroid: { + products: ['premium_yearly'], + purchaseToken: 'deferred-sub', + }, + purchaseState: 'purchased', + }), + ]); + }); + + it('ignores blank Vega subscription identifiers', async () => { + const service = createService(); + service.getPurchaseUpdates.mockResolvedValue({ + responseCode: 1, + receiptList: [ + { + receiptId: ' receipt-token-with-spaces ', + sku: ' ', + termSku: 'premium_monthly', + deferredSku: ' ', + productType: 3, + isDeferred: true, + }, + ], + }); + const module = createExpoIapVegaModule(service); + + await expect(module.getAvailableItems()).resolves.toEqual([ + expect.objectContaining({ + id: ' receipt-token-with-spaces ', + productId: 'premium_monthly', + purchaseToken: ' receipt-token-with-spaces ', + currentPlanId: 'premium_monthly', + pendingPurchaseUpdateAndroid: null, }), ]); }); diff --git a/libraries/expo-iap/src/vega-adapter.ts b/libraries/expo-iap/src/vega-adapter.ts index d7a9fcffa..99244946b 100644 --- a/libraries/expo-iap/src/vega-adapter.ts +++ b/libraries/expo-iap/src/vega-adapter.ts @@ -71,6 +71,7 @@ interface VegaProduct { interface VegaReceipt { cancelDate?: Date | number | string | null; deferredDate?: Date | number | string | null; + deferredSku?: string | null; isCancelled?: boolean | null; isDeferred?: boolean | null; productType?: unknown; @@ -559,8 +560,13 @@ function getSubscriptionPeriod(product: VegaProduct): string { return ''; } +function nonBlankString(value: unknown): string | null { + if (typeof value !== 'string') return null; + return value.trim().length > 0 ? value : null; +} + function getReceiptSku(receipt: VegaReceipt): string { - return receipt.sku ?? receipt.termSku ?? ''; + return nonBlankString(receipt.sku) ?? nonBlankString(receipt.termSku) ?? ''; } function getCachedProductType( @@ -700,19 +706,22 @@ function mapReceipt( const receiptId = receipt.receiptId ?? ''; const productId = productIdOverride ?? getReceiptSku(receipt); const type = productTypeToOpenIap(receipt.productType ?? fallbackProductType); - const isPending = Boolean(receipt.isDeferred); const isCanceled = Boolean(receipt.isCancelled || receipt.cancelDate); - const isActive = !isCanceled && !isPending; + const isActive = !isCanceled; + const deferredSku = nonBlankString(receipt.deferredSku); return { id: receiptId, productId, transactionDate: toTimestamp(receipt.purchaseDate), purchaseToken: receiptId, + currentPlanId: + type === 'subs' ? (nonBlankString(receipt.termSku) ?? productId) : null, + ids: productId ? [productId] : [], platform: 'android', store: 'amazon', quantity: 1, - purchaseState: isPending ? 'pending' : isActive ? 'purchased' : 'unknown', + purchaseState: isActive ? 'purchased' : 'unknown', isAutoRenewing: type === 'subs' && isActive, transactionId: receiptId, autoRenewingAndroid: type === 'subs' && isActive, @@ -723,7 +732,11 @@ function mapReceipt( obfuscatedAccountIdAndroid: null, obfuscatedProfileIdAndroid: null, developerPayloadAndroid: null, - isSuspendedAndroid: Boolean(receipt.isDeferred), + isSuspendedAndroid: false, + pendingPurchaseUpdateAndroid: + receipt.isDeferred && deferredSku + ? {products: [deferredSku], purchaseToken: receiptId} + : null, }; } @@ -956,16 +969,14 @@ export function createExpoIapVegaModule( }; const getAvailableItems = async ( - options?: PurchaseOptions, + _options?: PurchaseOptions, ): Promise => { - const includeSuspended = Boolean(options?.includeSuspendedAndroid ?? false); const receipts = await getPurchaseUpdateReceipts(); await hydrateProductTypesForReceipts(receipts); return receipts .filter((receipt) => { const isCanceled = Boolean(receipt.isCancelled || receipt.cancelDate); - if (isCanceled) return false; - return includeSuspended || !receipt.isDeferred; + return !isCanceled; }) .map((receipt) => mapReceipt( @@ -1026,7 +1037,7 @@ export function createExpoIapVegaModule( for (const receipt of receipts) { const isCanceled = Boolean(receipt.isCancelled || receipt.cancelDate); - if (isCanceled || receipt.isDeferred) continue; + if (isCanceled) continue; const purchaseTimestamp = toTimestamp(receipt.purchaseDate); if ( @@ -1449,8 +1460,8 @@ export function createExpoIapVegaModule( } ).autoRenewingAndroid ?? null) : null, - basePlanIdAndroid: purchase.productId, - currentPlanId: purchase.productId, + basePlanIdAndroid: purchase.currentPlanId ?? purchase.productId, + currentPlanId: purchase.currentPlanId ?? purchase.productId, purchaseTokenAndroid: purchase.purchaseToken ?? null, })); }, diff --git a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt index a5c32212b..7f370f3c7 100644 --- a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt +++ b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt @@ -1354,34 +1354,34 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act // Verify Purchase (Platform-specific, v8.0.0+) "verifyPurchase" -> { val googleOptions = call.argument>("google") + val horizonOptions = call.argument>("horizon") - // Android only supports google options - if (googleOptions == null) { - safe.error(OpenIapError.DeveloperError.CODE, "google options required for Android verification", null) + if ((googleOptions == null) == (horizonOptions == null)) { + safe.error( + OpenIapError.DeveloperError.CODE, + "Exactly one of google or horizon options is required for Android verification", + null + ) return } - val sku = googleOptions["sku"] as? String - val accessToken = googleOptions["accessToken"] as? String - val packageName = googleOptions["packageName"] as? String - val purchaseToken = googleOptions["purchaseToken"] as? String - val isSub = googleOptions["isSub"] as? Boolean - - // Validate required fields (sensitive data check) - if (accessToken.isNullOrBlank()) { - safe.error(OpenIapError.DeveloperError.CODE, "accessToken is required for Google verification", null) - return - } - if (packageName.isNullOrBlank()) { - safe.error(OpenIapError.DeveloperError.CODE, "packageName is required for Google verification", null) - return + val optionName = if (googleOptions != null) "google" else "horizon" + val optionLabel = if (googleOptions != null) "Google" else "Horizon" + val selectedOptions = googleOptions ?: horizonOptions!! + val requiredFields = if (googleOptions != null) { + listOf("accessToken", "packageName", "purchaseToken", "sku") + } else { + listOf("accessToken", "sku", "userId") } - if (purchaseToken.isNullOrBlank()) { - safe.error(OpenIapError.DeveloperError.CODE, "purchaseToken is required for Google verification", null) - return + val missingField = requiredFields.firstOrNull { + (selectedOptions[it] as? String).isNullOrBlank() } - if (sku.isNullOrBlank()) { - safe.error(OpenIapError.DeveloperError.CODE, "sku is required for Google verification", null) + if (missingField != null) { + safe.error( + OpenIapError.DeveloperError.CODE, + "$missingField is required for $optionLabel verification", + null + ) return } @@ -1394,50 +1394,25 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act return@withBillingReady } - // Build props for OpenIAP using new API structure - val propsMap = mapOf( - "google" to mapOf( - "sku" to sku, - "accessToken" to accessToken, - "packageName" to packageName, - "purchaseToken" to purchaseToken, - "isSub" to isSub - ) - ) + // Forward the complete platform payload and let the generated + // contract own both parsing and result serialization. + val propsMap = mapOf(optionName to selectedOptions) val props = dev.hyo.openiap.VerifyPurchaseProps.fromJson(propsMap) - val result = iap.verifyPurchase(props) - - // Convert result to JSON - val payload = JSONObject().apply { - put("__typename", "VerifyPurchaseResultAndroid") - // Add Android-specific result fields from OpenIAP result - when (result) { - is dev.hyo.openiap.VerifyPurchaseResultAndroid -> { - put("autoRenewing", result.autoRenewing) - put("betaProduct", result.betaProduct) - result.cancelDate?.let { put("cancelDate", it) } - result.cancelReason?.let { put("cancelReason", it) } - result.deferredDate?.let { put("deferredDate", it) } - result.deferredSku?.let { put("deferredSku", it) } - put("freeTrialEndDate", result.freeTrialEndDate) - put("gracePeriodEndDate", result.gracePeriodEndDate) - put("parentProductId", result.parentProductId) - put("productId", result.productId) - put("productType", result.productType) - put("purchaseDate", result.purchaseDate) - put("quantity", result.quantity) - put("receiptId", result.receiptId) - put("renewalDate", result.renewalDate) - put("term", result.term) - put("termSku", result.termSku) - put("testTransaction", result.testTransaction) - } - else -> { - OpenIapLog.warn("Unexpected verification result type: ${result::class.simpleName}", TAG) - } - } + val hasParsedOptions = if (optionName == "google") { + props.google != null + } else { + props.horizon != null + } + if (!hasParsedOptions) { + safe.error( + OpenIapError.DeveloperError.CODE, + "Invalid $optionName options for Android verification", + null + ) + return@withBillingReady } - safe.success(payload.toString()) + val result = iap.verifyPurchase(props) + safe.success(JSONObject(result.toJson()).toString()) } catch (e: Exception) { OpenIapLog.error("verifyPurchase error", e) safe.error(OpenIapError.VerificationFailed.CODE, "Verification failed: ${e.message}", null) @@ -1503,34 +1478,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act return@withBillingReady } val result = iap.verifyPurchaseWithProvider(props) - - // Convert result to JSON - val iapkitResult = result.iapkit?.let { item -> - JSONObject().apply { - put("isValid", item.isValid) - put("state", item.state.toJson()) - put("store", item.store.toJson()) - item.productId?.let { put("productId", it) } - item.clientPayload?.let { clientPayload -> - put( - "clientPayload", - JSONObject().apply { - put("format", clientPayload.format.toJson()) - put("body", clientPayload.body) - put("version", clientPayload.version) - put("updatedAt", clientPayload.updatedAt) - } - ) - } - } - } - val payload = JSONObject().apply { - put("provider", result.provider.toJson()) - if (iapkitResult != null) { - put("iapkit", iapkitResult) - } - } - safe.success(payload.toString()) + safe.success(JSONObject(result.toJson()).toString()) } catch (e: Exception) { OpenIapLog.error("verifyPurchaseWithProvider error", e) safe.error(OpenIapError.VerificationFailed.CODE, "Verification failed: ${e.message}", null) diff --git a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift index 578c97bc9..fd2948b0c 100644 --- a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift @@ -1006,6 +1006,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { return } var payload: [String: Any?] = [ + "__typename": "VerifyPurchaseResultIOS", "isValid": res.isValid, "receiptData": res.receiptData, // Provide both fields for compatibility with OpenIAP spec and legacy @@ -1087,29 +1088,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { let props = try JSONDecoder().decode(VerifyPurchaseWithProviderProps.self, from: jsonData) let res = try await OpenIapModule.shared.verifyPurchaseWithProvider(props) - // Convert result to dictionary - var payload: [String: Any] = [ - "provider": res.provider.rawValue - ] - if let iapkitItem = res.iapkit { - var iapkitResult: [String: Any] = [ - "isValid": iapkitItem.isValid, - "state": iapkitItem.state.rawValue, - "store": iapkitItem.store.rawValue - ] - if let productId = iapkitItem.productId { - iapkitResult["productId"] = productId - } - if let clientPayload = iapkitItem.clientPayload { - iapkitResult["clientPayload"] = [ - "format": clientPayload.format.rawValue, - "body": clientPayload.body, - "version": clientPayload.version, - "updatedAt": clientPayload.updatedAt - ] - } - payload["iapkit"] = iapkitResult - } + let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode(res)) FlutterIapLog.result("verifyPurchaseWithProvider", value: payload) result(payload) } catch let purchaseError as PurchaseError { diff --git a/libraries/flutter_inapp_purchase/lib/errors.dart b/libraries/flutter_inapp_purchase/lib/errors.dart index 4b091e95a..2032524ad 100644 --- a/libraries/flutter_inapp_purchase/lib/errors.dart +++ b/libraries/flutter_inapp_purchase/lib/errors.dart @@ -452,7 +452,10 @@ class ConnectionResult { ConnectionResult({this.msg}); ConnectionResult.fromJSON(Map json) - : msg = json['msg'] as String?; + : msg = json['msg'] as String? ?? + (json['connected'] is bool + ? ((json['connected'] as bool) ? 'connected' : 'disconnected') + : null); Map toJson() => {'msg': msg ?? ''}; diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index 362eadbfc..b50863e02 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -1136,10 +1136,12 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { final statuses = []; for (final entry in asList) { if (entry is Map) { - final normalized = entry.map( - (key, value) => MapEntry(key.toString(), value), - ); - statuses.add(gentype.SubscriptionStatusIOS.fromJson(normalized)); + final normalized = normalizeDynamicMap(entry); + if (normalized != null) { + statuses.add( + gentype.SubscriptionStatusIOS.fromJson(normalized), + ); + } } } return statuses; @@ -1202,11 +1204,10 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } if (result is Map) { - return gentype.ProductIOS.fromJson( - result.map( - (key, value) => MapEntry(key.toString(), value), - ), - ); + final normalized = normalizeDynamicMap(result); + return normalized == null + ? null + : gentype.ProductIOS.fromJson(normalized); } if (result is String) { @@ -2019,16 +2020,17 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); } - final validationResult = result.map( - (key, value) => MapEntry(key.toString(), value), - ); + final validationResult = normalizeDynamicMap(result); + if (validationResult == null) { + throw PurchaseError( + code: gentype.ErrorCode.ServiceError, + message: + 'Invalid validation result received from native platform', + ); + } final latestTransactionMap = validationResult['latestTransaction']; final latestTransaction = latestTransactionMap is Map - ? gentype.Purchase.fromJson( - latestTransactionMap.map( - (key, value) => MapEntry(key.toString(), value), - ), - ) + ? normalizeDynamicMap(latestTransactionMap) : null; return gentype.VerifyPurchaseResultIOS( @@ -2036,7 +2038,9 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { jwsRepresentation: validationResult['jwsRepresentation']?.toString() ?? '', receiptData: validationResult['receiptData']?.toString() ?? '', - latestTransaction: latestTransaction, + latestTransaction: latestTransaction == null + ? null + : gentype.Purchase.fromJson(latestTransaction), ); } on PlatformException catch (error) { throw PurchaseError( @@ -2345,13 +2349,11 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); } - final Map resultMap; + final Map? resultMap; if (result is String) { - resultMap = jsonDecode(result) as Map; + resultMap = normalizeDynamicMap(jsonDecode(result)); } else if (result is Map) { - resultMap = result.map( - (k, v) => MapEntry(k.toString(), v), - ); + resultMap = normalizeDynamicMap(result); } else { throw PurchaseError( code: gentype.ErrorCode.PurchaseVerificationFailed, @@ -2359,6 +2361,14 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); } + if (resultMap == null) { + throw PurchaseError( + code: gentype.ErrorCode.PurchaseVerificationFailed, + message: + 'Invalid verification result received from native platform', + ); + } + return gentype.VerifyPurchaseResult.fromJson(resultMap); } on PlatformException catch (error) { throw PurchaseError( diff --git a/libraries/flutter_inapp_purchase/lib/helpers.dart b/libraries/flutter_inapp_purchase/lib/helpers.dart index f23142b6a..798ee5520 100644 --- a/libraries/flutter_inapp_purchase/lib/helpers.dart +++ b/libraries/flutter_inapp_purchase/lib/helpers.dart @@ -577,7 +577,7 @@ iap_err.PurchaseError convertToPurchaseError( code = gentype.ErrorCode.AlreadyOwned; break; case 8: - code = gentype.ErrorCode.PurchaseError; + code = gentype.ErrorCode.ItemNotOwned; break; } } @@ -1168,7 +1168,7 @@ Map? normalizeDynamicMap(dynamic value) { } dynamic normalizeDynamicValue(dynamic value) { - if (value is Map || value is Map) { + if (value is Map) { return normalizeDynamicMap(value); } if (value is List) { diff --git a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift index f5b1545c9..1bf478e52 100644 --- a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift @@ -941,6 +941,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { return } var payload: [String: Any?] = [ + "__typename": "VerifyPurchaseResultIOS", "isValid": res.isValid, "receiptData": res.receiptData, // Provide both fields for compatibility with OpenIAP spec and legacy @@ -1020,28 +1021,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { let props = try JSONDecoder().decode(VerifyPurchaseWithProviderProps.self, from: jsonData) let res = try await OpenIapModule.shared.verifyPurchaseWithProvider(props) - var payload: [String: Any] = [ - "provider": res.provider.rawValue - ] - if let iapkitItem = res.iapkit { - var iapkitResult: [String: Any] = [ - "isValid": iapkitItem.isValid, - "state": iapkitItem.state.rawValue, - "store": iapkitItem.store.rawValue - ] - if let productId = iapkitItem.productId { - iapkitResult["productId"] = productId - } - if let clientPayload = iapkitItem.clientPayload { - iapkitResult["clientPayload"] = [ - "format": clientPayload.format.rawValue, - "body": clientPayload.body, - "version": clientPayload.version, - "updatedAt": clientPayload.updatedAt - ] - } - payload["iapkit"] = iapkitResult - } + let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode(res)) FlutterIapLog.result("verifyPurchaseWithProvider", value: payload) result(payload) } catch let purchaseError as PurchaseError { diff --git a/libraries/flutter_inapp_purchase/test/errors_unit_test.dart b/libraries/flutter_inapp_purchase/test/errors_unit_test.dart index 1849ac202..63cf309a5 100644 --- a/libraries/flutter_inapp_purchase/test/errors_unit_test.dart +++ b/libraries/flutter_inapp_purchase/test/errors_unit_test.dart @@ -267,6 +267,21 @@ void main() { expect(parsed.toString(), contains('connected')); }); + test('ConnectionResult accepts the native connected flag', () { + expect( + errors.ConnectionResult.fromJSON( + {'connected': true}, + ).msg, + 'connected', + ); + expect( + errors.ConnectionResult.fromJSON( + {'connected': false}, + ).msg, + 'disconnected', + ); + }); + test( 'message-based inference removed - returns Unknown for "User cancelled the operation"', () { diff --git a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_active_subscriptions_test.dart b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_active_subscriptions_test.dart index fb104a94f..1acfb5e96 100644 --- a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_active_subscriptions_test.dart +++ b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_active_subscriptions_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; @@ -29,6 +30,39 @@ void main() { expect(result, isEmpty); }); + test('extractPurchases does not log sensitive malformed payload data', () { + final previousDebugPrint = debugPrint; + final messages = []; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) messages.add(message); + }; + + try { + final result = extractPurchases( + >[ + { + 'dataAndroid': '{"purchaseToken":"secret-native-token"}', + 'platform': 'android', + 'purchaseStateAndroid': 1, + 'purchaseToken': 'secret-canonical-token', + 'store': 'google', + }, + ], + platformIsAndroid: true, + platformIsIOS: false, + acknowledgedAndroidPurchaseTokens: {}, + ); + + expect(result, isEmpty); + } finally { + debugPrint = previousDebugPrint; + } + + final output = messages.join('\n'); + expect(output, isNot(contains('secret-native-token'))); + expect(output, isNot(contains('secret-canonical-token'))); + }); + group('getActiveSubscriptions', () { test( 'returns active Android subscriptions only for purchased items', diff --git a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart index f7ce8dcba..402d52e4d 100644 --- a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart +++ b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart @@ -2012,7 +2012,7 @@ void main() { codec.encodeMethodCall( MethodCall( 'connection-updated', - jsonEncode({'msg': 'connected'}), + jsonEncode({'connected': true}), ), ), (_) {}, @@ -2022,6 +2022,36 @@ void main() { expect(result.msg, 'connected'); }); + test('connection-updated derives the disconnected message', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + if (call.method == 'initConnection') { + return true; + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + final connectionFuture = iap.connectionUpdated.first; + + await iap.initConnection(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + codec.encodeMethodCall( + MethodCall( + 'connection-updated', + jsonEncode({'connected': false}), + ), + ), + (_) {}, + ); + + expect((await connectionFuture).msg, 'disconnected'); + }); + test('iap-promoted-product emits the productId', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { @@ -2373,11 +2403,23 @@ void main() { case 'initConnection': return true; case 'verifyPurchase': - return { + return { '__typename': 'VerifyPurchaseResultIOS', 'isValid': true, 'jwsRepresentation': 'test-jws-representation', 'receiptData': 'test-receipt-data', + 'latestTransaction': { + '__typename': 'PurchaseIOS', + 'id': 'ios-transaction-id', + 'isAutoRenewing': false, + 'platform': 'ios', + 'productId': 'premium.upgrade', + 'purchaseState': 'purchased', + 'quantity': 1, + 'store': 'apple', + 'transactionDate': 1705315800000.0, + 'transactionId': 'ios-transaction-id', + }, }; } return null; @@ -2410,6 +2452,7 @@ void main() { final iosResult = result as types.VerifyPurchaseResultIOS; expect(iosResult.isValid, true); expect(iosResult.jwsRepresentation, 'test-jws-representation'); + expect(iosResult.latestTransaction?.productId, 'premium.upgrade'); }); test('sends correct payload for Android verification', () async { @@ -2480,6 +2523,55 @@ void main() { expect(androidResult.autoRenewing, false); }); + test('sends and parses Horizon verification payloads', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + calls.add(call); + switch (call.method) { + case 'initConnection': + return true; + case 'verifyPurchase': + return jsonEncode({ + '__typename': 'VerifyPurchaseResultHorizon', + 'grantTime': 1705315800, + 'success': true, + }); + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + await iap.initConnection(); + + final result = await iap.verifyPurchase( + horizon: const types.VerifyPurchaseHorizonOptions( + accessToken: 'test-horizon-access-token', + sku: 'premium.upgrade', + userId: 'horizon-user-id', + ), + ); + + final verifyCall = calls.singleWhere( + (MethodCall call) => call.method == 'verifyPurchase', + ); + final payload = normalizeDynamicMap(verifyCall.arguments)!; + expect(payload['google'], isNull); + expect( + payload['horizon'], + containsPair('accessToken', 'test-horizon-access-token'), + ); + expect(payload['horizon'], containsPair('sku', 'premium.upgrade')); + expect(payload['horizon'], containsPair('userId', 'horizon-user-id')); + + expect(result, isA()); + final horizonResult = result as types.VerifyPurchaseResultHorizon; + expect(horizonResult.success, isTrue); + expect(horizonResult.grantTime, 1705315800); + }); + test('throws PurchaseError on platform exception', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { diff --git a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart index 411f96a3d..318e2acfd 100644 --- a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart +++ b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart @@ -1060,6 +1060,15 @@ void main() { platform: types.IapPlatform.Android, ); expect(responseMapped.code, types.ErrorCode.AlreadyOwned); + + final notOwnedMapped = convertToPurchaseError( + PurchaseResult( + responseCode: 8, + message: 'item is not owned', + ), + platform: types.IapPlatform.Android, + ); + expect(notOwnedMapped.code, types.ErrorCode.ItemNotOwned); }); test('normalizeDynamicMap coerces keys and nested structures', () { diff --git a/libraries/flutter_inapp_purchase/test/iapkit_base_url_bridge_test.dart b/libraries/flutter_inapp_purchase/test/iapkit_base_url_bridge_test.dart index 33e0e0510..476216401 100644 --- a/libraries/flutter_inapp_purchase/test/iapkit_base_url_bridge_test.dart +++ b/libraries/flutter_inapp_purchase/test/iapkit_base_url_bridge_test.dart @@ -27,7 +27,7 @@ void main() { ); }); - test('native plugins forward and return IAPKit client payloads', () { + test('native plugins use generated provider-result serialization', () { final ios = File( 'ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift', ).readAsStringSync(); @@ -40,11 +40,41 @@ void main() { for (final apple in [ios, macos]) { expect(apple, contains('iapkit["includeClientPayload"] as? Bool')); - expect(apple, contains('iapkitResult["clientPayload"]')); - expect(apple, contains('iapkitItem.productId')); + expect( + apple, + contains( + 'FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode(res))', + ), + ); } expect(android, contains('iapkit["includeClientPayload"] as? Boolean')); - expect(android, contains('item.clientPayload?.let')); - expect(android, contains('item.productId?.let')); + expect( + android, + contains('safe.success(JSONObject(result.toJson()).toString())'), + ); + }); + + test('native plugins preserve verification result discriminators', () { + final ios = File( + 'ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift', + ).readAsStringSync(); + final macos = File( + 'macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift', + ).readAsStringSync(); + final android = File( + 'android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt', + ).readAsStringSync(); + + for (final apple in [ios, macos]) { + expect( + apple, + contains('"__typename": "VerifyPurchaseResultIOS"'), + ); + } + expect( + android, + contains('val horizonOptions = call.argument>'), + ); + expect(android, contains('JSONObject(result.toJson()).toString()')); }); } diff --git a/libraries/flutter_inapp_purchase/test/ios_methods_test.dart b/libraries/flutter_inapp_purchase/test/ios_methods_test.dart index a78723b17..b50e4970c 100644 --- a/libraries/flutter_inapp_purchase/test/ios_methods_test.dart +++ b/libraries/flutter_inapp_purchase/test/ios_methods_test.dart @@ -257,6 +257,44 @@ void main() { expect(calls.last.method, 'getPromotedProductIOS'); }); + test('getPromotedProductIOS normalizes nested platform maps', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == 'getPromotedProductIOS') { + return { + 'currency': 'USD', + 'description': 'Subscription', + 'displayNameIOS': 'Premium', + 'displayPrice': r'$4.99', + 'id': 'com.example.premium', + 'isFamilyShareableIOS': true, + 'jsonRepresentationIOS': '{}', + 'platform': 'ios', + 'price': 4.99, + 'subscriptionInfoIOS': { + 'subscriptionGroupId': 'premium-group', + 'subscriptionPeriod': { + 'unit': 'month', + 'value': 1, + }, + }, + 'title': 'Premium', + 'type': 'subs', + 'typeIOS': 'auto-renewable-subscription', + }; + } + return null; + }); + + final product = await iap.getPromotedProductIOS(); + expect( + product?.subscriptionInfoIOS?.subscriptionGroupId, 'premium-group'); + expect( + product?.subscriptionInfoIOS?.subscriptionPeriod.unit, + SubscriptionPeriodIOS.Month, + ); + }); + test('getPendingTransactionsIOS returns purchases list', () async { final list = await iap.getPendingTransactionsIOS(); expect(list, isA>()); @@ -307,6 +345,43 @@ void main() { expect(calls.last.method, 'validateReceiptIOS'); }); + test('validateReceiptIOS normalizes a nested native transaction', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == 'initConnection') { + return true; + } + if (methodCall.method == 'validateReceiptIOS') { + return { + '__typename': 'VerifyPurchaseResultIOS', + 'isValid': true, + 'jwsRepresentation': 'nested-jws', + 'receiptData': 'nested-receipt', + 'latestTransaction': { + '__typename': 'PurchaseIOS', + 'id': 'nested-transaction', + 'isAutoRenewing': false, + 'platform': 'ios', + 'productId': 'com.example.prod1', + 'purchaseState': 'purchased', + 'quantity': 1, + 'store': 'apple', + 'transactionDate': 1700000000000, + 'transactionId': 'nested-transaction', + }, + }; + } + return null; + }); + + await iap.initConnection(); + final result = await iap.validateReceiptIOS( + apple: const VerifyPurchaseAppleOptions(sku: 'com.example.prod1'), + ); + + expect(result.latestTransaction?.id, 'nested-transaction'); + }); + test('validateReceiptIOS throws when connection not initialized', () async { await expectLater( iap.validateReceiptIOS( @@ -406,6 +481,26 @@ void main() { expect(calls.last.method, 'subscriptionStatusIOS'); }); + test('subscriptionStatusIOS normalizes nested renewal info', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == 'subscriptionStatusIOS') { + return [ + { + 'state': 'active', + 'renewalInfo': { + 'willAutoRenew': true, + }, + }, + ]; + } + return null; + }); + + final statuses = await iap.subscriptionStatusIOS('sku'); + expect(statuses.single.renewalInfo?.willAutoRenew, isTrue); + }); + test('subscriptionStatusIOS accepts string payload', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { diff --git a/libraries/godot-iap/Example/tests/test_types_only.gd b/libraries/godot-iap/Example/tests/test_types_only.gd index d7baa0f63..3e076a8a9 100644 --- a/libraries/godot-iap/Example/tests/test_types_only.gd +++ b/libraries/godot-iap/Example/tests/test_types_only.gd @@ -692,6 +692,14 @@ func _test_purchase_android_json_round_trip() -> void: purchase.purchase_state = Types.PurchaseState.PURCHASED purchase.is_auto_renewing = true purchase.is_acknowledged_android = true + purchase.current_plan_id = "base-plan-monthly" + purchase.data_android = "{\"orderId\":\"txn-1\"}" + purchase.is_suspended_android = true + var pending_update = Types.PendingPurchaseUpdateAndroid.new() + var pending_products: Array[String] = ["sku.b"] + pending_update.products = pending_products + pending_update.purchase_token = "pending-token" + purchase.pending_purchase_update_android = pending_update var dict = purchase.to_dict() _assert_equal(dict["store"], "google", "IapStore should serialize to its wire value") @@ -707,6 +715,20 @@ func _test_purchase_android_json_round_trip() -> void: _assert_equal(parsed.quantity, 2, "quantity should survive JSON float conversion") _assert_equal(parsed.is_acknowledged_android, true, "isAcknowledgedAndroid should survive the wire round trip") _assert_equal(parsed.ids.size(), 1, "ids should survive the wire round trip") + _assert_equal(parsed.transaction_id, "txn-1", "transactionId should survive the wire round trip") + _assert_equal(parsed.current_plan_id, "base-plan-monthly", "currentPlanId should survive the wire round trip") + _assert_equal(parsed.data_android, "{\"orderId\":\"txn-1\"}", "dataAndroid should survive the wire round trip") + _assert_equal(parsed.is_suspended_android, true, "isSuspendedAndroid should survive the wire round trip") + _assert_equal( + parsed.pending_purchase_update_android.products[0], + "sku.b", + "pendingPurchaseUpdateAndroid products should survive the wire round trip" + ) + _assert_equal( + parsed.pending_purchase_update_android.purchase_token, + "pending-token", + "pendingPurchaseUpdateAndroid token should survive the wire round trip" + ) var unknown_state = Types.PurchaseAndroid.from_dict({"productId": "p", "purchaseState": "mystery"}) _assert_equal(unknown_state.purchase_state, Types.PurchaseState.UNKNOWN, "Unknown purchaseState strings should fall back to UNKNOWN") @@ -728,6 +750,21 @@ func _test_purchase_ios_json_round_trip() -> void: purchase.quantity = 1 purchase.purchase_state = Types.PurchaseState.PURCHASED purchase.original_transaction_identifier_ios = "orig-1" + purchase.current_plan_id = "premium.monthly" + var offer = Types.PurchaseOfferIOS.new() + offer.id = "launch-offer" + offer.type = "promotional" + offer.payment_mode = "payAsYouGo" + purchase.offer_ios = offer + var advanced_info = Types.AdvancedCommerceInfoIOS.new() + advanced_info.request_reference_id = "request-reference" + advanced_info.display_name = "Premium bundle" + var advanced_item = Types.AdvancedCommerceItemIOS.new() + var advanced_details = Types.AdvancedCommerceItemDetailsIOS.new() + advanced_details.json_representation = "{\"sku\":\"ios.sku\"}" + advanced_item.details = advanced_details + advanced_info.items.append(advanced_item) + purchase.advanced_commerce_info_ios = advanced_info var wire = JSON.parse_string(JSON.stringify(purchase.to_dict())) _assert_equal(wire["store"], "apple", "IapStore should serialize to apple") @@ -737,6 +774,18 @@ func _test_purchase_ios_json_round_trip() -> void: _assert_equal(parsed.product_id, "ios.sku", "productId should survive the wire round trip") _assert_equal(parsed.store, Types.IapStore.APPLE, "store should parse back to the enum") _assert_equal(parsed.original_transaction_identifier_ios, "orig-1", "iOS-only fields should survive the wire round trip") + _assert_equal(parsed.current_plan_id, "premium.monthly", "currentPlanId should survive the wire round trip") + _assert_equal(parsed.offer_ios.id, "launch-offer", "offerIOS should survive the wire round trip") + _assert_equal( + parsed.advanced_commerce_info_ios.request_reference_id, + "request-reference", + "advancedCommerceInfoIOS should survive the wire round trip" + ) + _assert_equal( + parsed.advanced_commerce_info_ios.items[0].details.json_representation, + "{\"sku\":\"ios.sku\"}", + "advancedCommerceInfoIOS nested item details should survive the wire round trip" + ) func _test_active_subscription_round_trip() -> void: diff --git a/libraries/kmp-iap/library/build.gradle.kts b/libraries/kmp-iap/library/build.gradle.kts index d2989abfc..4ef11348f 100644 --- a/libraries/kmp-iap/library/build.gradle.kts +++ b/libraries/kmp-iap/library/build.gradle.kts @@ -349,6 +349,9 @@ dependencies { add("horizonCompileOnly", "com.android.billingclient:billing:$playBillingVersion") add("amazonCompileOnly", "com.android.billingclient:billing:$playBillingVersion") add("androidUnitTestImplementation", "com.android.billingclient:billing:$playBillingVersion") + // BillingClient Purchase parses Android JSONObject state internally; use + // the same Android-aware JVM harness as packages/google for those tests. + add("androidUnitTestImplementation", "org.robolectric:robolectric:4.13") // openiap-google keeps gson implementation-scoped, so tests that replicate // its reflective parse of Play Developer API responses need it explicitly. add("androidUnitTestImplementation", "com.google.code.gson:gson:2.10.1") diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt index 68d932b86..466cb0437 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt @@ -12,6 +12,7 @@ import com.android.billingclient.api.BillingResult import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryProductDetailsResult +import io.github.hyochan.kmpiap.openiap.ActiveSubscription import io.github.hyochan.kmpiap.openiap.BillingProgramAndroid import io.github.hyochan.kmpiap.openiap.DiscountAmountAndroid import io.github.hyochan.kmpiap.openiap.DiscountDisplayInfoAndroid @@ -39,6 +40,7 @@ import io.github.hyochan.kmpiap.openiap.Purchase import io.github.hyochan.kmpiap.openiap.SubscriptionOffer import io.github.hyochan.kmpiap.openiap.PaymentMode import io.github.hyochan.kmpiap.openiap.DiscountOfferType +import io.github.hyochan.kmpiap.openiap.PendingPurchaseUpdateAndroid import io.github.hyochan.kmpiap.openiap.PurchaseAndroid import io.github.hyochan.kmpiap.openiap.PurchaseError import io.github.hyochan.kmpiap.openiap.PurchaseState @@ -486,11 +488,17 @@ internal fun com.android.billingclient.api.Purchase.toPurchase(): Purchase { } val accountIdentifiers = accountIdentifiers + val pendingUpdate = runCatching { pendingPurchaseUpdate }.getOrNull()?.let { update -> + PendingPurchaseUpdateAndroid( + products = update.products, + purchaseToken = update.purchaseToken, + ) + } return PurchaseAndroid( autoRenewingAndroid = isAutoRenewing, dataAndroid = originalJson, - developerPayloadAndroid = null, + developerPayloadAndroid = developerPayload, id = orderId ?: purchaseToken, ids = products, isAcknowledgedAndroid = isAcknowledged, @@ -499,6 +507,7 @@ internal fun com.android.billingclient.api.Purchase.toPurchase(): Purchase { obfuscatedAccountIdAndroid = accountIdentifiers?.obfuscatedAccountId, obfuscatedProfileIdAndroid = accountIdentifiers?.obfuscatedProfileId, packageNameAndroid = packageName, + pendingPurchaseUpdateAndroid = pendingUpdate, platform = IapPlatform.Android, productId = products.firstOrNull() ?: "", store = IapStore.Google, @@ -506,10 +515,23 @@ internal fun com.android.billingclient.api.Purchase.toPurchase(): Purchase { purchaseToken = purchaseToken, quantity = quantity, signatureAndroid = signature, - transactionDate = purchaseTime.toOpenIapTransactionDate() + transactionDate = purchaseTime.toOpenIapTransactionDate(), + transactionId = orderId, ) } +internal fun com.android.billingclient.api.Purchase.toActiveSubscription(): ActiveSubscription = + ActiveSubscription( + autoRenewingAndroid = isAutoRenewing, + isActive = purchaseState == + com.android.billingclient.api.Purchase.PurchaseState.PURCHASED, + productId = products.firstOrNull().orEmpty(), + purchaseToken = purchaseToken, + purchaseTokenAndroid = purchaseToken, + transactionDate = purchaseTime.toOpenIapTransactionDate(), + transactionId = orderId ?: purchaseToken, + ) + internal fun ProductDetails.toProduct(): Product { val oneTime = oneTimePurchaseOfferDetails val allOneTimeOffers = runCatching { oneTimePurchaseOfferDetailsList }.getOrNull().orEmpty() diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt index 135f3b84a..a5010c0b1 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt @@ -1953,20 +1953,7 @@ internal class InAppPurchaseAndroid( .filter { purchase -> purchase.purchaseState == com.android.billingclient.api.Purchase.PurchaseState.PURCHASED - }.map { purchase -> - ActiveSubscription( - autoRenewingAndroid = purchase.isAutoRenewing, - isActive = true, - productId = purchase.products.firstOrNull().orEmpty(), - purchaseToken = purchase.purchaseToken, - transactionDate = purchase.purchaseTime.toOpenIapTransactionDate(), - transactionId = purchase.orderId ?: purchase.purchaseToken, - willExpireSoon = null, - daysUntilExpirationIOS = null, - environmentIOS = null, - expirationDateIOS = null - ) - } + }.map { purchase -> purchase.toActiveSubscription() } complete(Result.success(active)) } else { val error = result.toBillingOperationError( diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/BillingPurchasePayloadMappingTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/BillingPurchasePayloadMappingTest.kt new file mode 100644 index 000000000..7a3dc9834 --- /dev/null +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/BillingPurchasePayloadMappingTest.kt @@ -0,0 +1,66 @@ +package io.github.hyochan.kmpiap + +import com.android.billingclient.api.Purchase as BillingPurchase +import io.github.hyochan.kmpiap.openiap.PurchaseAndroid +import io.github.hyochan.kmpiap.openiap.PurchaseState +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(RobolectricTestRunner::class) +class BillingPurchasePayloadMappingTest { + private val originalJson = + """ + { + "orderId": "order-premium", + "packageName": "dev.hyo.martie", + "productId": "premium.monthly", + "productIds": ["premium.monthly"], + "purchaseTime": 1700000000000, + "purchaseState": 0, + "purchaseToken": "purchase-token", + "quantity": 2, + "acknowledged": true, + "autoRenewing": true, + "developerPayload": "developer-payload", + "pendingPurchaseUpdate": { + "productIds": ["premium.yearly"], + "purchaseToken": "pending-token" + } + } + """.trimIndent() + + private fun billingPurchase() = BillingPurchase(originalJson, "signature") + + @Test + fun `preserves canonical billing purchase metadata`() { + val purchase = billingPurchase().toPurchase() as PurchaseAndroid + + assertEquals(originalJson, purchase.dataAndroid) + assertEquals("developer-payload", purchase.developerPayloadAndroid) + assertEquals("order-premium", purchase.transactionId) + assertEquals(PurchaseState.Purchased, purchase.purchaseState) + assertEquals("signature", purchase.signatureAndroid) + assertEquals(2, purchase.quantity) + val pendingUpdate = assertNotNull(purchase.pendingPurchaseUpdateAndroid) + assertEquals( + listOf("premium.yearly"), + pendingUpdate.products, + ) + assertEquals( + "pending-token", + pendingUpdate.purchaseToken, + ) + } + + @Test + fun `preserves Android purchase token for active subscription replacement`() { + val activeSubscription = billingPurchase().toActiveSubscription() + + assertEquals("purchase-token", activeSubscription.purchaseToken) + assertEquals("purchase-token", activeSubscription.purchaseTokenAndroid) + assertEquals("order-premium", activeSubscription.transactionId) + } +} diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index 04c44a5b0..2fc06d49b 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -1224,84 +1224,13 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { return convertAnyToPurchaseIOS(data) } - @Suppress("UNCHECKED_CAST") - private fun convertAnyToPurchaseIOS(data: Any?): PurchaseIOS? { - if (data == null) return null - - return try { - // OpenIAP returns NSDictionary which can be cast to Map - val dict = (data as? Map<*, *>) ?: return null - val map = normalizeBridgeMap(dict) ?: return null - - val platform = map["platform"] as? String - if (platform == "ios" || platform == "iOS") { - PurchaseIOS( - appAccountToken = map["appAccountToken"] as? String, - appBundleIdIOS = map["appBundleIdIOS"] as? String, - billingPlanTypeIOS = (map["billingPlanTypeIOS"] as? String)?.let { - SubscriptionBillingPlanTypeIOS.fromJson(it) - }, - commitmentInfoIOS = convertAnyToTransactionCommitmentInfoIOS( - map["commitmentInfoIOS"] - ), - countryCodeIOS = map["countryCodeIOS"] as? String, - currencyCodeIOS = map["currencyCodeIOS"] as? String, - currencySymbolIOS = map["currencySymbolIOS"] as? String, - environmentIOS = map["environmentIOS"] as? String, - expirationDateIOS = (map["expirationDateIOS"] as? Number)?.toDouble(), - id = map["id"] as? String ?: "", - ids = (map["ids"] as? List<*>)?.mapNotNull { it as? String }, - isAutoRenewing = map["isAutoRenewing"] as? Boolean ?: false, - isUpgradedIOS = map["isUpgradedIOS"] as? Boolean, - offerIOS = null, // Complex object, handle separately if needed - originalTransactionDateIOS = (map["originalTransactionDateIOS"] as? Number)?.toDouble(), - originalTransactionIdentifierIOS = map["originalTransactionIdentifierIOS"] as? String, - ownershipTypeIOS = map["ownershipTypeIOS"] as? String, - platform = IapPlatform.Ios, - productId = map["productId"] as? String ?: "", - store = IapStore.Apple, - purchaseState = (map["purchaseState"] as? String)?.let { - PurchaseState.fromJson(it) - } ?: PurchaseState.Unknown, - purchaseToken = map["purchaseToken"] as? String, - quantity = (map["quantity"] as? Number)?.toInt() ?: 1, - quantityIOS = (map["quantityIOS"] as? Number)?.toInt(), - reasonIOS = map["reasonIOS"] as? String, - reasonStringRepresentationIOS = map["reasonStringRepresentationIOS"] as? String, - renewalInfoIOS = convertAnyToRenewalInfoIOS(map["renewalInfoIOS"]), - revocationDateIOS = (map["revocationDateIOS"] as? Number)?.toDouble(), - revocationReasonIOS = map["revocationReasonIOS"] as? String, - storefrontCountryCodeIOS = map["storefrontCountryCodeIOS"] as? String, - subscriptionGroupIdIOS = map["subscriptionGroupIdIOS"] as? String, - transactionDate = (map["transactionDate"] as? Number)?.toDouble() ?: 0.0, - transactionId = map["transactionId"] as? String ?: "", - transactionReasonIOS = map["transactionReasonIOS"] as? String, - webOrderLineItemIdIOS = map["webOrderLineItemIdIOS"] as? String - ) - } else { - null - } - } catch (e: Exception) { - null - } - } + private fun convertAnyToPurchaseIOS(data: Any?): PurchaseIOS? = + decodePurchasePayloadIOS(data) private fun mapFromAny(data: Any?): Map? { return normalizeBridgeMap(data) } - private fun convertAnyToTransactionCommitmentInfoIOS(data: Any?): TransactionCommitmentInfoIOS? { - return mapFromAny(data)?.let { map -> - runCatching { TransactionCommitmentInfoIOS.fromJson(map) }.getOrNull() - } - } - - private fun convertAnyToRenewalInfoIOS(data: Any?): RenewalInfoIOS? { - return mapFromAny(data)?.let { map -> - runCatching { RenewalInfoIOS.fromJson(map) }.getOrNull() - } - } - private fun convertAnyListToSubscriptionPricingTermsIOS(data: Any?): List? { val list = data as? List<*> ?: return null val decoded = mutableListOf() diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt index da5160a54..a5b9824eb 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt @@ -1,5 +1,12 @@ package io.github.hyochan.kmpiap +import io.github.hyochan.kmpiap.openiap.AdvancedCommerceInfoIOS +import io.github.hyochan.kmpiap.openiap.PurchaseIOS +import io.github.hyochan.kmpiap.openiap.PurchaseOfferIOS +import io.github.hyochan.kmpiap.openiap.RenewalInfoIOS +import io.github.hyochan.kmpiap.openiap.TransactionCommitmentInfoIOS +import platform.Foundation.NSNull + internal fun normalizeBridgeMap(data: Any?): Map? { val source = data as? Map<*, *> ?: return null return source.entries.associate { (key, value) -> @@ -31,7 +38,79 @@ internal fun normalizeProductPayloadIOS(data: Any?): Map? { return normalized } +internal fun normalizePurchasePayloadIOS(data: Any?): Map? { + val normalized = normalizeBridgeMap(data)?.toMutableMap() ?: return null + + // Preserve every canonical PurchaseIOS field from the native dictionary. + // These defaults only cover legacy bridge payloads that predate the + // generated platform/store/quantity fields. + if (normalized["platform"] == null) normalized["platform"] = "ios" + if (normalized["store"] == null) normalized["store"] = "apple" + if (normalized["quantity"] == null) { + normalized["quantity"] = normalized["quantityIOS"] as? Number ?: 1 + } + if ((normalized["platform"] as? String)?.equals("ios", ignoreCase = true) == true) { + normalized["platform"] = "ios" + } + if ((normalized["store"] as? String)?.equals("apple", ignoreCase = true) == true) { + normalized["store"] = "apple" + } + val id = (normalized["id"] as? String)?.takeIf { it.isNotBlank() } + val transactionId = (normalized["transactionId"] as? String)?.takeIf { it.isNotBlank() } + if (id == null && transactionId != null) normalized["id"] = transactionId + if (transactionId == null && id != null) normalized["transactionId"] = id + return normalized +} + +internal fun decodePurchasePayloadIOS(data: Any?): PurchaseIOS? { + val normalized = normalizePurchasePayloadIOS(data) ?: return null + if (normalized["platform"] != "ios") return null + if ((normalized["productId"] as? String).isNullOrBlank()) return null + if ( + (normalized["id"] as? String).isNullOrBlank() || + (normalized["transactionId"] as? String).isNullOrBlank() + ) { + return null + } + + runCatching { PurchaseIOS.fromJson(normalized) }.getOrNull()?.let { return it } + + // A malformed optional native object must not suppress an otherwise-valid + // purchase update. Validate each structured field independently, discard + // only the field that cannot be decoded, and retry the generated decoder. + val fallback = normalized.toMutableMap() + fallback.removeMalformedPurchaseObjectIOS( + "advancedCommerceInfoIOS", + AdvancedCommerceInfoIOS::fromJson, + ) + fallback.removeMalformedPurchaseObjectIOS( + "commitmentInfoIOS", + TransactionCommitmentInfoIOS::fromJson, + ) + fallback.removeMalformedPurchaseObjectIOS( + "offerIOS", + PurchaseOfferIOS::fromJson, + ) + fallback.removeMalformedPurchaseObjectIOS( + "renewalInfoIOS", + RenewalInfoIOS::fromJson, + ) + return runCatching { PurchaseIOS.fromJson(fallback) }.getOrNull() +} + +private fun MutableMap.removeMalformedPurchaseObjectIOS( + key: String, + decode: (Map) -> Any, +) { + val value = this[key] ?: return + val normalizedObject = normalizeBridgeMap(value) + if (normalizedObject == null || runCatching { decode(normalizedObject) }.isFailure) { + remove(key) + } +} + private fun normalizeBridgeValue(value: Any?): Any? = when (value) { + is NSNull -> null is Map<*, *> -> value.entries.associate { (key, nested) -> key.toString() to normalizeBridgeValue(nested) } diff --git a/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt index 8674d9444..555ebc8b0 100644 --- a/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt +++ b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt @@ -1,6 +1,8 @@ package io.github.hyochan.kmpiap import io.github.hyochan.kmpiap.openiap.ProductSubscriptionIOS +import io.github.hyochan.kmpiap.openiap.SubscriptionBillingPlanTypeIOS +import platform.Foundation.NSNull import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -50,4 +52,164 @@ class ProductPayloadNormalizerTestIOS { assertEquals("intro", product.subscriptionOffers?.single()?.id) assertEquals("legacy-discount", product.discountsIOS?.single()?.identifier) } + + @Test + fun `preserves canonical purchase metadata before generated decoding`() { + val payload: Map = mapOf( + "platform" to "ios", + "store" to "apple", + "id" to "transaction-1", + "productId" to "premium.monthly", + "purchaseState" to "purchased", + "purchaseToken" to "signed-jws", + "quantity" to 1, + "transactionDate" to 1_700_000_000_000.0, + "advancedCommerceInfoIOS" to mapOf( + "items" to emptyList(), + "requestReferenceId" to "request-reference", + ), + "billingPlanTypeIOS" to "monthly", + "commitmentInfoIOS" to mapOf( + "billingPeriodNumber" to 2, + "commitmentExpiresDate" to 1_800_000_000_000.0, + "commitmentPrice" to 9.99, + "totalBillingPeriods" to 12, + ), + "currentPlanId" to "monthly-plan", + "isAutoRenewing" to true, + "offerIOS" to mapOf( + "id" to "offer-id", + "paymentMode" to "pay-as-you-go", + "type" to "promotional", + ), + "renewalInfoIOS" to mapOf( + "pendingUpgradeProductId" to "premium.yearly", + "willAutoRenew" to true, + ), + ) + + val purchase = assertNotNull(decodePurchasePayloadIOS(payload)) + + assertEquals("request-reference", purchase.advancedCommerceInfoIOS?.requestReferenceId) + assertEquals(SubscriptionBillingPlanTypeIOS.Monthly, purchase.billingPlanTypeIOS) + assertEquals(12, purchase.commitmentInfoIOS?.totalBillingPeriods) + assertEquals("monthly-plan", purchase.currentPlanId) + assertEquals("offer-id", purchase.offerIOS?.id) + assertEquals("premium.yearly", purchase.renewalInfoIOS?.pendingUpgradeProductId) + } + + @Test + fun `adds legacy purchase defaults without overwriting canonical values`() { + val normalized = assertNotNull( + normalizePurchasePayloadIOS( + mapOf( + "platform" to NSNull(), + "store" to "apple", + "quantity" to NSNull(), + "renewalInfoIOS" to mapOf( + "pendingUpgradeProductId" to NSNull(), + ), + ) + ) + ) + + assertEquals("ios", normalized["platform"]) + assertEquals("apple", normalized["store"]) + assertEquals(1, normalized["quantity"]) + assertEquals( + null, + (normalized["renewalInfoIOS"] as Map<*, *>)["pendingUpgradeProductId"], + ) + } + + @Test + fun `does not decode a non iOS purchase payload`() { + val purchase = decodePurchasePayloadIOS( + mapOf( + "platform" to "android", + "id" to "purchase-token", + ) + ) + + assertEquals(null, purchase) + } + + @Test + fun `canonicalizes legacy iOS discriminator casing before generated decoding`() { + val purchase = assertNotNull( + decodePurchasePayloadIOS( + mapOf( + "platform" to "iOS", + "store" to "Apple", + "id" to "transaction-legacy", + "productId" to "premium.monthly", + "purchaseState" to "purchased", + "quantity" to 1, + "transactionDate" to 1_700_000_000_000.0, + "transactionId" to "transaction-legacy", + ) + ) + ) + + assertEquals("transaction-legacy", purchase.id) + } + + @Test + fun `keeps purchase when optional advanced commerce payload is malformed`() { + val purchase = assertNotNull( + decodePurchasePayloadIOS( + mapOf( + "platform" to "ios", + "store" to "apple", + "id" to "transaction-1", + "productId" to "premium.monthly", + "purchaseState" to "purchased", + "quantity" to 1, + "transactionDate" to 1_700_000_000_000.0, + "transactionId" to "transaction-1", + "advancedCommerceInfoIOS" to mapOf( + "items" to listOf("not-an-object"), + ), + "renewalInfoIOS" to mapOf( + "pendingUpgradeProductId" to "premium.yearly", + "willAutoRenew" to true, + ), + ) + ) + ) + + assertEquals(null, purchase.advancedCommerceInfoIOS) + assertEquals("premium.yearly", purchase.renewalInfoIOS?.pendingUpgradeProductId) + } + + @Test + fun `recovers legacy purchase identity and quantity aliases`() { + val purchase = assertNotNull( + decodePurchasePayloadIOS( + mapOf( + "id" to "transaction-legacy", + "productId" to "premium.monthly", + "purchaseState" to "purchased", + "quantityIOS" to 2, + "transactionDate" to 1_700_000_000_000.0, + ) + ) + ) + + assertEquals("transaction-legacy", purchase.transactionId) + assertEquals(2, purchase.quantity) + } + + @Test + fun `rejects purchase payload without core identity`() { + val purchase = decodePurchasePayloadIOS( + mapOf( + "platform" to "ios", + "productId" to "premium.monthly", + "purchaseState" to "purchased", + ) + ) + + assertEquals(null, purchase) + } } diff --git a/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.cs b/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.cs index 5060715f4..ca43a4ae5 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.cs @@ -74,12 +74,8 @@ private void WireListeners() { _module.AddPurchaseUpdatedListener(new EventBridge(json => { - try - { - var purchase = JsonSerializer.Deserialize(json, JsonOptions.Default); - if (purchase is not null) _purchaseUpdated.OnNext(purchase); - } - catch (JsonException) { /* malformed payload — ignore */ } + var purchase = DeserializeListenerPayload(json, "purchaseUpdated"); + if (purchase is not null) _purchaseUpdated.OnNext(purchase); })); _module.AddPurchaseErrorListener(new EventBridge(json => @@ -89,35 +85,56 @@ private void WireListeners() _module.AddSubscriptionBillingIssueListener(new EventBridge(json => { - try - { - var purchase = JsonSerializer.Deserialize(json, JsonOptions.Default); - if (purchase is not null) _subscriptionBillingIssue.OnNext(purchase); - } - catch (JsonException) { } + var purchase = DeserializeListenerPayload(json, "subscriptionBillingIssue"); + if (purchase is not null) _subscriptionBillingIssue.OnNext(purchase); })); _module.AddUserChoiceBillingAndroidListener(new EventBridge(json => { - try - { - var details = JsonSerializer.Deserialize(json, JsonOptions.Default); - if (details is not null) _userChoiceBillingAndroid.OnNext(details); - } - catch (JsonException) { } + var details = DeserializeListenerPayload( + json, + "userChoiceBillingAndroid"); + if (details is not null) _userChoiceBillingAndroid.OnNext(details); })); _module.AddDeveloperProvidedBillingAndroidListener(new EventBridge(json => { - try - { - var details = JsonSerializer.Deserialize(json, JsonOptions.Default); - if (details is not null) _developerProvidedBillingAndroid.OnNext(details); - } - catch (JsonException) { } + var details = DeserializeListenerPayload( + json, + "developerProvidedBillingAndroid"); + if (details is not null) _developerProvidedBillingAndroid.OnNext(details); })); } + private static T? DeserializeListenerPayload(string json, string listenerName) + where T : class + { + try + { + return JsonSerializer.Deserialize(json, JsonOptions.Default) + ?? throw new JsonException("Listener payload deserialized to null"); + } + catch (JsonException ex) + { + ReportListenerFailure(listenerName, ex); + return null; + } + catch (NotSupportedException ex) + { + ReportListenerFailure(listenerName, ex); + return null; + } + } + + private static void ReportListenerFailure(string listenerName, Exception exception) + { + // Do not include the raw payload: purchase events can contain receipt + // data and tokens. The listener and exception types are enough to expose + // bridge/schema drift without leaking purchase credentials. + Console.WriteLine( + $"[OpenIapAndroid] {listenerName} listener failed ({exception.GetType().Name})"); + } + // ------------------------------------------------------------------- // Generic module → Task adapter // ------------------------------------------------------------------- diff --git a/libraries/maui-iap/tests/OpenIap.Maui.Tests/RecordJsonTests.cs b/libraries/maui-iap/tests/OpenIap.Maui.Tests/RecordJsonTests.cs index eb820badb..e990d797f 100644 --- a/libraries/maui-iap/tests/OpenIap.Maui.Tests/RecordJsonTests.cs +++ b/libraries/maui-iap/tests/OpenIap.Maui.Tests/RecordJsonTests.cs @@ -3,6 +3,7 @@ // JsonOptions.Default the library uses for every module payload. using System.Text.Json; +using System.Text.Json.Serialization; using Xunit; namespace OpenIap.Maui.Tests; @@ -131,16 +132,124 @@ public void ProductAndroid_DefaultsPlatformAndTypeWhenOmitted() private const string PurchaseAndroidJson = """ { "__typename": "PurchaseAndroid", - "id": "GPA.1234-5678", + "autoRenewingAndroid": true, + "currentPlanId": "monthly-base-plan", + "dataAndroid": "{\"orderId\":\"GPA.1234-5678\"}", + "developerPayloadAndroid": "developer-payload", + "id": "token-abc", + "ids": ["premium.monthly", "premium.backup"], + "isAcknowledgedAndroid": false, "isAutoRenewing": true, + "isSuspendedAndroid": true, + "obfuscatedAccountIdAndroid": "account-hash", + "obfuscatedProfileIdAndroid": "profile-hash", + "packageNameAndroid": "dev.hyo.martie", + "pendingPurchaseUpdateAndroid": { + "products": ["premium.annual"], + "purchaseToken": "pending-token" + }, "platform": "android", "productId": "premium.monthly", "purchaseState": "purchased", "purchaseToken": "token-abc", "quantity": 1, + "signatureAndroid": "signature-abc", "store": "google", "transactionDate": 1720000000000, - "isAcknowledgedAndroid": false + "transactionId": "GPA.1234-5678" + } + """; + + private const string PurchaseIosJson = """ + { + "__typename": "PurchaseIOS", + "advancedCommerceInfoIOS": { + "description": "Advanced commerce purchase", + "displayName": "Premium bundle", + "estimatedTax": "0.99", + "items": [ + { + "details": { + "jsonRepresentation": "{\"sku\":\"premium.bundle\"}" + }, + "refunds": [ + { + "jsonRepresentation": "{\"reason\":\"partial\"}" + } + ], + "revocationDate": 1720000000111 + } + ], + "requestReferenceId": "request-reference", + "taxCode": "digital-goods", + "taxExclusivePrice": "9.00", + "taxRate": "0.11" + }, + "appAccountToken": "11111111-2222-3333-4444-555555555555", + "appBundleIdIOS": "dev.hyo.martie", + "billingPlanTypeIOS": "monthly", + "commitmentInfoIOS": { + "billingPeriodNumber": 3, + "commitmentExpiresDate": 1750000000000, + "commitmentPrice": 9.99, + "totalBillingPeriods": 12 + }, + "countryCodeIOS": "US", + "currencyCodeIOS": "USD", + "currencySymbolIOS": "$", + "currentPlanId": "premium.monthly", + "environmentIOS": "Sandbox", + "expirationDateIOS": 1722592000000, + "id": "2000000123", + "ids": ["premium.monthly"], + "isAutoRenewing": true, + "isUpgradedIOS": false, + "offerIOS": { + "id": "launch-offer", + "paymentMode": "payAsYouGo", + "type": "promotional" + }, + "originalTransactionDateIOS": 1710000000000, + "originalTransactionIdentifierIOS": "2000000001", + "ownershipTypeIOS": "PURCHASED", + "platform": "ios", + "productId": "premium.monthly", + "purchaseState": "purchased", + "purchaseToken": "signed-jws", + "quantity": 1, + "quantityIOS": 1, + "reasonIOS": "PURCHASE", + "reasonStringRepresentationIOS": "purchase", + "renewalInfoIOS": { + "autoRenewPreference": "premium.annual", + "commitmentInfo": { + "commitmentAutoRenewProductId": "premium.annual", + "commitmentAutoRenewStatus": true, + "commitmentRenewalBillingPlanType": "up-front", + "commitmentRenewalDate": 1750000000000, + "commitmentRenewalPrice": 99.99 + }, + "expirationReason": "VOLUNTARY", + "gracePeriodExpirationDate": 1723000000000, + "isInBillingRetry": true, + "jsonRepresentation": "{\"renewal\":\"metadata\"}", + "pendingUpgradeProductId": "premium.annual", + "priceIncreaseStatus": "AGREED", + "renewalBillingPlanType": "up-front", + "renewalDate": 1722592000000, + "renewalOfferId": "renewal-offer", + "renewalOfferType": "PROMOTIONAL", + "willAutoRenew": true + }, + "revocationDateIOS": 1724000000000, + "revocationReasonIOS": "REFUNDED", + "store": "apple", + "storefrontCountryCodeIOS": "USA", + "subscriptionGroupIdIOS": "group.premium", + "transactionDate": 1720000000000, + "transactionId": "2000000123", + "transactionReasonIOS": "PURCHASE", + "webOrderLineItemIdIOS": "1000000999" } """; @@ -150,17 +259,18 @@ public void Purchase_Union_DeserializesPurchaseAndroidViaDiscriminator() var purchase = JsonSerializer.Deserialize(PurchaseAndroidJson, Options); var android = Assert.IsType(purchase); - Assert.Equal("GPA.1234-5678", android.Id); + Assert.Equal("token-abc", android.Id); Assert.Equal(IapPlatform.Android, android.Platform); Assert.Equal(PurchaseState.Purchased, android.PurchaseState); Assert.Equal(IapStore.Google, android.Store); Assert.Equal("token-abc", android.PurchaseToken); + Assert.Equal("GPA.1234-5678", android.TransactionId); Assert.Equal(1720000000000D, android.TransactionDate); Assert.True(android.IsAutoRenewing); Assert.NotNull(android.IsAcknowledgedAndroid); Assert.False(android.IsAcknowledgedAndroid!.Value); - Assert.Null(android.Ids); - Assert.Null(android.SignatureAndroid); + Assert.Equal(["premium.monthly", "premium.backup"], android.Ids); + Assert.Equal("signature-abc", android.SignatureAndroid); } [Fact] @@ -169,7 +279,44 @@ public void Purchase_Union_RoundTripsPurchaseAndroid() var purchase = JsonSerializer.Deserialize(PurchaseAndroidJson, Options)!; var serialized = JsonSerializer.Serialize(purchase, Options); Assert.Contains("\"__typename\":\"PurchaseAndroid\"", serialized, StringComparison.Ordinal); - Assert.Equal(purchase, JsonSerializer.Deserialize(serialized, Options)); + var reparsed = Assert.IsType( + JsonSerializer.Deserialize(serialized, Options)); + Assert.Equal("GPA.1234-5678", reparsed.TransactionId); + Assert.Equal(["premium.monthly", "premium.backup"], reparsed.Ids); + Assert.Equal(["premium.annual"], reparsed.PendingPurchaseUpdateAndroid?.Products); + } + + [Fact] + public void PurchaseAndroid_FullCanonicalPayloadRoundTripsEveryGeneratedField() + { + var android = AssertFullPurchaseRoundTrip(PurchaseAndroidJson); + + Assert.Equal("monthly-base-plan", android.CurrentPlanId); + Assert.Equal("""{"orderId":"GPA.1234-5678"}""", android.DataAndroid); + Assert.True(android.IsSuspendedAndroid); + Assert.NotNull(android.PendingPurchaseUpdateAndroid); + Assert.Equal(["premium.annual"], android.PendingPurchaseUpdateAndroid!.Products); + Assert.Equal("pending-token", android.PendingPurchaseUpdateAndroid.PurchaseToken); + Assert.Equal("GPA.1234-5678", android.TransactionId); + } + + [Fact] + public void PurchaseIOS_FullCanonicalPayloadRoundTripsEveryGeneratedField() + { + var ios = AssertFullPurchaseRoundTrip(PurchaseIosJson); + + Assert.Equal("premium.monthly", ios.CurrentPlanId); + Assert.Equal("launch-offer", ios.OfferIOS?.Id); + Assert.Equal(SubscriptionBillingPlanTypeIOS.Monthly, ios.BillingPlanTypeIOS); + Assert.Equal(3, ios.CommitmentInfoIOS?.BillingPeriodNumber); + Assert.Equal("request-reference", ios.AdvancedCommerceInfoIOS?.RequestReferenceId); + Assert.Equal( + """{"sku":"premium.bundle"}""", + ios.AdvancedCommerceInfoIOS?.Items[0].Details?.JsonRepresentation); + Assert.Equal("premium.annual", ios.RenewalInfoIOS?.PendingUpgradeProductId); + Assert.Equal( + SubscriptionBillingPlanTypeIOS.UpFront, + ios.RenewalInfoIOS?.CommitmentInfo?.CommitmentRenewalBillingPlanType); } [Fact] @@ -218,6 +365,40 @@ public void Purchase_MissingRequiredPropertyThrows() Assert.Throws(() => JsonSerializer.Deserialize(json, Options)); } + private static TPurchase AssertFullPurchaseRoundTrip(string json) + where TPurchase : Purchase + { + using var fixture = JsonDocument.Parse(json); + var expectedWireFields = typeof(TPurchase) + .GetProperties() + .Select(property => property.GetCustomAttributes(typeof(JsonPropertyNameAttribute), false) + .Cast() + .Single() + .Name) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + + foreach (var wireField in expectedWireFields) + { + Assert.True( + fixture.RootElement.TryGetProperty(wireField, out _), + $"Full canonical {typeof(TPurchase).Name} fixture is missing {wireField}"); + } + + var purchase = Assert.IsType(JsonSerializer.Deserialize(json, Options)); + var serialized = JsonSerializer.Serialize(purchase, Options); + using var roundTrip = JsonDocument.Parse(serialized); + + foreach (var wireField in expectedWireFields) + { + Assert.True( + roundTrip.RootElement.TryGetProperty(wireField, out _), + $"{typeof(TPurchase).Name} round trip dropped {wireField}"); + } + + return Assert.IsType(JsonSerializer.Deserialize(serialized, Options)); + } + // ------------------------------------------------------------------ // PurchaseError record // ------------------------------------------------------------------ diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index 27ace3681..9217d3a27 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -1308,6 +1308,7 @@ class HybridRnIap : HybridRnIapSpec() { } return NitroPurchase( id = purchase.id, + transactionId = androidPurchase?.transactionId.wrapVariant(), productId = purchase.productId, transactionDate = purchase.transactionDate, purchaseToken = purchase.purchaseToken.wrapVariant(), diff --git a/libraries/react-native-iap/ios/RnIapHelper.swift b/libraries/react-native-iap/ios/RnIapHelper.swift index ed35bad36..389210aba 100644 --- a/libraries/react-native-iap/ios/RnIapHelper.swift +++ b/libraries/react-native-iap/ios/RnIapHelper.swift @@ -392,6 +392,7 @@ enum RnIapHelper { return NitroPurchase( id: dictionary["id"] as? String ?? "", + transactionId: wrapString(dictionary["transactionId"] as? String), productId: dictionary["productId"] as? String ?? "", transactionDate: doubleValue(dictionary["transactionDate"]) ?? 0, purchaseToken: wrapString(dictionary["purchaseToken"] as? String), @@ -489,8 +490,8 @@ enum RnIapHelper { priceIncreaseStatus: wrapString(dictionary["priceIncreaseStatus"] as? String), renewalBillingPlanType: (dictionary["renewalBillingPlanType"] as? String) .flatMap(SubscriptionBillingPlanTypeIOS.init(fromString:)), - renewalOfferType: wrapString(dictionary["offerType"] as? String), - renewalOfferId: wrapString(dictionary["offerIdentifier"] as? String), + renewalOfferType: wrapString(dictionary["renewalOfferType"] as? String), + renewalOfferId: wrapString(dictionary["renewalOfferId"] as? String), jsonRepresentation: wrapString(dictionary["jsonRepresentation"] as? String) ) } diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index ce546fe45..2d9ab0a97 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -1850,13 +1850,23 @@ describe('Public API (src/index.ts)', () => { renewalInfoIOS: { willAutoRenew: true, autoRenewPreference: 'subscription1', - expirationIntent: null, - gracePeriodExpiresAt: null, - offerType: null, - originalTransactionId: 'trans1', + commitmentInfo: { + commitmentAutoRenewProductId: 'subscription1', + commitmentAutoRenewStatus: true, + commitmentRenewalBillingPlanType: 'monthly', + commitmentRenewalDate: Date.now() + 86400000, + commitmentRenewalPrice: 9.99, + }, + pendingUpgradeProductId: 'subscription2', + expirationReason: null, + isInBillingRetry: false, + gracePeriodExpirationDate: null, priceIncreaseStatus: null, + renewalBillingPlanType: 'monthly', + renewalOfferType: 'promotional', + renewalOfferId: 'summer-offer', + jsonRepresentation: '{"source":"storekit"}', renewalDate: Date.now() + 86400000, - signedDate: Date.now(), }, }, ]; @@ -1875,6 +1885,14 @@ describe('Public API (src/index.ts)', () => { isActive: true, renewalInfoIOS: expect.objectContaining({ willAutoRenew: true, + commitmentInfo: expect.objectContaining({ + commitmentAutoRenewProductId: 'subscription1', + }), + pendingUpgradeProductId: 'subscription2', + renewalBillingPlanType: 'monthly', + renewalOfferType: 'promotional', + renewalOfferId: 'summer-offer', + jsonRepresentation: '{"source":"storekit"}', }), }), ); diff --git a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts index db526f7ce..62c4f5bce 100644 --- a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts +++ b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts @@ -12,6 +12,7 @@ import type { NitroPurchase, NitroSubscriptionStatus, } from '../../specs/RnIap.nitro'; +import type {PurchaseAndroid} from '../../types'; describe('type-bridge utilities', () => { describe('convertNitroProductToProduct', () => { @@ -419,11 +420,13 @@ describe('type-bridge utilities', () => { const result = convertNitroPurchaseToPurchase(nitroPurchase); expect(result.platform).toBe('ios'); expect(result.purchaseState).toBe('purchased'); + expect(result.transactionId).toBe('tx-ios'); }); it('preserves common and StoreKit purchase metadata', () => { const nitroPurchase = { id: 'tx-ios-metadata', + transactionId: 'canonical-tx-ios-metadata', productId: 'sku-ios', transactionDate: 123, platform: 'ios', @@ -459,6 +462,7 @@ describe('type-bridge utilities', () => { expect.objectContaining({ currentPlanId: 'premium-monthly', ids: ['sku-ios', 'item-addon'], + transactionId: 'canonical-tx-ios-metadata', advancedCommerceInfoIOS: {items: []}, billingPlanTypeIOS: 'monthly', commitmentInfoIOS: expect.objectContaining({totalBillingPeriods: 12}), @@ -510,7 +514,8 @@ describe('type-bridge utilities', () => { it('converts Android purchases and maps purchase state', () => { const nitroPurchase: NitroPurchase = { - id: 'tx-android', + id: 'token-android', + transactionId: 'order-android', productId: 'sku-android', transactionDate: 456, purchaseTokenAndroid: 'token-android', @@ -526,8 +531,73 @@ describe('type-bridge utilities', () => { expect(result.platform).toBe('android'); expect(result.purchaseState).toBe('purchased'); expect(result.autoRenewingAndroid).toBe(true); + expect(result.transactionId).toBe('order-android'); }); + it('does not treat an orderless Android purchase token as transactionId', () => { + const nitroPurchase: NitroPurchase = { + id: 'pending-purchase-token', + transactionId: null, + productId: 'sku-android', + transactionDate: 456, + purchaseTokenAndroid: 'pending-purchase-token', + platform: 'android', + store: 'google', + quantity: 1, + purchaseState: 'pending', + isAutoRenewing: false, + }; + + const result = convertNitroPurchaseToPurchase( + nitroPurchase, + ) as PurchaseAndroid; + expect(result.id).toBe('pending-purchase-token'); + expect(result.transactionId).toBeNull(); + }); + + it('recovers a legacy Google order ID that differs from its token', () => { + const nitroPurchase = { + id: 'GPA.1234-5678', + productId: 'sku-android', + transactionDate: 456, + purchaseToken: 'purchase-token', + purchaseTokenAndroid: 'purchase-token', + platform: 'android', + store: 'google', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + } as NitroPurchase; + + const result = convertNitroPurchaseToPurchase( + nitroPurchase, + ) as PurchaseAndroid; + expect(result.transactionId).toBe('GPA.1234-5678'); + }); + + it.each(['amazon', 'horizon'] as const)( + 'recovers a legacy %s receipt ID even when it is also the token', + (store) => { + const nitroPurchase = { + id: `${store}-receipt`, + productId: 'sku-android', + transactionDate: 456, + purchaseToken: `${store}-receipt`, + purchaseTokenAndroid: `${store}-receipt`, + platform: 'android', + store, + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + } as NitroPurchase; + + const result = convertNitroPurchaseToPurchase( + nitroPurchase, + ) as PurchaseAndroid; + expect(result.transactionId).toBe(`${store}-receipt`); + }, + ); + it('preserves Android pending purchase metadata', () => { const nitroPurchase = { id: 'tx-pending-update', diff --git a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts index a4592de89..1a9250584 100644 --- a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts @@ -249,12 +249,14 @@ describe('Amazon Vega adapter', () => { purchaseToken: 'receipt-1', currentPlanId: null, store: 'amazon', + transactionId: 'receipt-1', }), ]); expect(listener).toHaveBeenCalledWith( expect.objectContaining({ productId: 'coins_100', purchaseToken: 'receipt-1', + transactionId: 'receipt-1', }), ); @@ -972,6 +974,10 @@ describe('Amazon Vega adapter', () => { 'receipt-page-1', 'receipt-page-2', ]); + expect(purchases.map((purchase) => purchase.transactionId)).toEqual([ + 'receipt-page-1', + 'receipt-page-2', + ]); }); it('treats Amazon parser-only purchase update errors as no updates', async () => { @@ -1123,37 +1129,82 @@ describe('Amazon Vega adapter', () => { expect(service.getProductData.mock.calls[1]?.[0].skus).toHaveLength(1); }); - it('excludes suspended purchases unless requested', async () => { + it('keeps deferred subscription changes active and exposes the upcoming plan', async () => { const service = createService(); service.getPurchaseUpdates.mockResolvedValue({ responseCode: 1, receiptList: [ { receiptId: 'deferred-sub', - sku: 'premium_monthly', + sku: 'premium', + termSku: 'premium_monthly', + deferredSku: 'premium_yearly', productType: 3, isDeferred: true, }, ], }); - const module = createVegaIapModule(service); - - await expect( - module.getAvailablePurchases({ - android: {type: 'subs', includeSuspended: false}, - }), - ).resolves.toEqual([]); + const module = createVegaIapModule(service) as ReturnType< + typeof createVegaIapModule + > & { + restorePurchases(): Promise; + }; + const listener = jest.fn(); + module.addPurchaseUpdatedListener(listener); await expect( - module.getAvailablePurchases({ - android: {type: 'subs', includeSuspended: true}, - }), + module.getAvailablePurchases({android: {type: 'subs'}}), ).resolves.toEqual([ expect.objectContaining({ id: 'deferred-sub', - isAutoRenewing: false, - isSuspendedAndroid: true, - purchaseState: 'pending', + productId: 'premium', + currentPlanId: 'premium_monthly', + isAutoRenewing: true, + isSuspendedAndroid: false, + pendingPurchaseUpdateAndroid: { + products: ['premium_yearly'], + purchaseToken: 'deferred-sub', + }, + purchaseState: 'purchased', + }), + ]); + await expect(module.restorePurchases()).resolves.toBeUndefined(); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'deferred-sub', + isSuspendedAndroid: false, + pendingPurchaseUpdateAndroid: { + products: ['premium_yearly'], + purchaseToken: 'deferred-sub', + }, + }), + ); + }); + + it('ignores blank Vega subscription identifiers', async () => { + const service = createService(); + service.getPurchaseUpdates.mockResolvedValue({ + responseCode: 1, + receiptList: [ + { + receiptId: ' receipt-token-with-spaces ', + sku: ' ', + termSku: 'premium_monthly', + deferredSku: ' ', + productType: 3, + isDeferred: true, + }, + ], + }); + const module = createVegaIapModule(service); + + await expect(module.getAvailablePurchases()).resolves.toEqual([ + expect.objectContaining({ + id: ' receipt-token-with-spaces ', + productId: 'premium_monthly', + purchaseToken: ' receipt-token-with-spaces ', + currentPlanId: 'premium_monthly', + pendingPurchaseUpdateAndroid: null, }), ]); }); diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index 21b4479b9..f6b9dc82e 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -2694,6 +2694,7 @@ export const getActiveSubscriptions: QueryField< willAutoRenew: sub.renewalInfoIOS.willAutoRenew ?? false, autoRenewPreference: sub.renewalInfoIOS.autoRenewPreference ?? null, + commitmentInfo: sub.renewalInfoIOS.commitmentInfo ?? null, pendingUpgradeProductId: sub.renewalInfoIOS.pendingUpgradeProductId ?? null, renewalDate: sub.renewalInfoIOS.renewalDate ?? null, @@ -2703,8 +2704,11 @@ export const getActiveSubscriptions: QueryField< sub.renewalInfoIOS.gracePeriodExpirationDate ?? null, priceIncreaseStatus: sub.renewalInfoIOS.priceIncreaseStatus ?? null, + renewalBillingPlanType: + sub.renewalInfoIOS.renewalBillingPlanType ?? null, renewalOfferType: sub.renewalInfoIOS.renewalOfferType ?? null, renewalOfferId: sub.renewalInfoIOS.renewalOfferId ?? null, + jsonRepresentation: sub.renewalInfoIOS.jsonRepresentation ?? null, } : null, // Android specific fields diff --git a/libraries/react-native-iap/src/specs/RnIap.nitro.ts b/libraries/react-native-iap/src/specs/RnIap.nitro.ts index 31d9cfcbe..3b29a3665 100644 --- a/libraries/react-native-iap/src/specs/RnIap.nitro.ts +++ b/libraries/react-native-iap/src/specs/RnIap.nitro.ts @@ -609,6 +609,7 @@ export interface NitroOneTimePurchaseOfferDetail { export interface NitroPurchase { id: PurchaseCommon['id']; + transactionId?: string | null; productId: PurchaseCommon['productId']; transactionDate: PurchaseCommon['transactionDate']; purchaseToken?: PurchaseCommon['purchaseToken']; diff --git a/libraries/react-native-iap/src/utils/type-bridge.ts b/libraries/react-native-iap/src/utils/type-bridge.ts index 7488b3b00..5fd835b7f 100644 --- a/libraries/react-native-iap/src/utils/type-bridge.ts +++ b/libraries/react-native-iap/src/utils/type-bridge.ts @@ -470,8 +470,9 @@ export function convertNitroPurchaseToPurchase( isAutoRenewing: Boolean(nitroPurchase.isAutoRenewing), currentPlanId: toNullableString(nitroPurchase.currentPlanId), ids: nitroPurchase.ids ?? null, - // PurchaseIOS requires both id and transactionId (they are the same value) - transactionId: nitroPurchase.id, + // PurchaseIOS requires a transaction ID; legacy native payloads used id. + transactionId: + toNullableString(nitroPurchase.transactionId) ?? nitroPurchase.id, advancedCommerceInfoIOS: nitroPurchase.advancedCommerceInfoIOS ?? null, billingPlanTypeIOS: nitroPurchase.billingPlanTypeIOS ?? null, commitmentInfoIOS: nitroPurchase.commitmentInfoIOS ?? null, @@ -525,6 +526,20 @@ export function convertNitroPurchaseToPurchase( return iosPurchase; } + const explicitAndroidTransactionId = toNullableString( + nitroPurchase.transactionId, + ); + const legacyAndroidId = toNullableString(nitroPurchase.id); + const androidPurchaseToken = toNullableString( + nitroPurchase.purchaseToken ?? nitroPurchase.purchaseTokenAndroid, + ); + const androidTransactionId = + explicitAndroidTransactionId ?? + (legacyAndroidId != null && + (store !== STORE_GOOGLE || legacyAndroidId !== androidPurchaseToken) + ? legacyAndroidId + : null); + const androidPurchase: PurchaseAndroid = { id: nitroPurchase.id, productId: nitroPurchase.productId, @@ -538,8 +553,9 @@ export function convertNitroPurchaseToPurchase( isAutoRenewing: Boolean(nitroPurchase.isAutoRenewing), currentPlanId: toNullableString(nitroPurchase.currentPlanId), ids: nitroPurchase.ids ?? null, - // PurchaseAndroid has optional transactionId (may differ from id/orderId) - transactionId: toNullableString(nitroPurchase.id), + // Android id falls back to purchaseToken when Play has no orderId, so do + // not synthesize a transactionId from it. + transactionId: androidTransactionId, autoRenewingAndroid: toNullableBoolean( nitroPurchase.autoRenewingAndroid ?? nitroPurchase.isAutoRenewing, ), diff --git a/libraries/react-native-iap/src/vega-adapter.ts b/libraries/react-native-iap/src/vega-adapter.ts index 1a18bda23..6a52ae4af 100644 --- a/libraries/react-native-iap/src/vega-adapter.ts +++ b/libraries/react-native-iap/src/vega-adapter.ts @@ -54,6 +54,7 @@ interface VegaProduct { interface VegaReceipt { cancelDate?: Date | number | string | null; deferredDate?: Date | number | string | null; + deferredSku?: string | null; isCancelled?: boolean | null; isDeferred?: boolean | null; productType?: unknown; @@ -127,7 +128,6 @@ const FULFILLMENT_RESULT_FULFILLED = 1; const RESPONSE_SUCCESS = 1; const PURCHASE_RESPONSE_SUCCESS = 0; const PURCHASE_STATE_PURCHASED = 1; -const PURCHASE_STATE_PENDING = 2; const IAPKIT_DEFAULT_BASE_URL = 'https://kit.openiap.dev'; const IAPKIT_VERIFY_PATH = '/v1/purchase/verify'; const VEGA_PARSER_ERROR_MESSAGES = [ @@ -575,8 +575,13 @@ function getSubscriptionPeriod(product: VegaProduct): string { return ''; } +function nonBlankString(value: unknown): string | null { + if (typeof value !== 'string') return null; + return value.trim().length > 0 ? value : null; +} + function getReceiptSku(receipt: VegaReceipt): string { - return receipt.sku ?? receipt.termSku ?? ''; + return nonBlankString(receipt.sku) ?? nonBlankString(receipt.termSku) ?? ''; } function getCachedProductType( @@ -712,33 +717,39 @@ function mapReceipt( const receiptId = receipt.receiptId ?? ''; const productId = productIdOverride ?? getReceiptSku(receipt); const type = productTypeToOpenIap(receipt.productType ?? fallbackProductType); - const isPending = Boolean(receipt.isDeferred); const isCanceled = Boolean(receipt.isCancelled || receipt.cancelDate); - const isActive = !isCanceled && !isPending; + const isActive = !isCanceled; + const deferredSku = nonBlankString(receipt.deferredSku); return { id: receiptId, + transactionId: receiptId, productId, transactionDate: toTimestamp(receipt.purchaseDate), purchaseToken: receiptId, - currentPlanId: type === 'subs' ? productId : null, + currentPlanId: + type === 'subs' ? (nonBlankString(receipt.termSku) ?? productId) : null, ids: productId ? [productId] : [], platform: 'android', store: 'amazon', quantity: 1, - purchaseState: isPending ? 'pending' : isActive ? 'purchased' : 'unknown', + purchaseState: isActive ? 'purchased' : 'unknown', isAutoRenewing: type === 'subs' && isActive, purchaseTokenAndroid: receiptId, dataAndroid: stringifyJson(receipt), signatureAndroid: null, autoRenewingAndroid: type === 'subs' && isActive, - purchaseStateAndroid: isPending - ? PURCHASE_STATE_PENDING - : isActive - ? PURCHASE_STATE_PURCHASED - : 0, + purchaseStateAndroid: isActive ? PURCHASE_STATE_PURCHASED : 0, isAcknowledgedAndroid: false, - isSuspendedAndroid: Boolean(receipt.isDeferred), + packageNameAndroid: null, + obfuscatedAccountIdAndroid: null, + obfuscatedProfileIdAndroid: null, + developerPayloadAndroid: null, + isSuspendedAndroid: false, + pendingPurchaseUpdateAndroid: + receipt.isDeferred && deferredSku + ? {products: [deferredSku], purchaseToken: receiptId} + : null, }; } @@ -1041,13 +1052,11 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { options?: Parameters[0], ): Promise => { const requestedType = options?.android?.type; - const includeSuspended = Boolean(options?.android?.includeSuspended); const receipts = await getPurchaseUpdateReceipts(); await hydrateProductTypesForReceipts(receipts); return receipts .filter((receipt) => { if (receipt.isCancelled || receipt.cancelDate) return false; - if (!includeSuspended && receipt.isDeferred) return false; const openIapType = productTypeToOpenIap( receipt.productType ?? getCachedProductType(receipt, productTypesBySku), @@ -1129,7 +1138,7 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { const requestedPurchases: NitroPurchase[] = []; for (const receipt of receipts) { - if (receipt.isCancelled || receipt.cancelDate || receipt.isDeferred) { + if (receipt.isCancelled || receipt.cancelDate) { continue; } @@ -1530,8 +1539,8 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { purchaseToken: purchase.purchaseToken ?? null, transactionDate: purchase.transactionDate, autoRenewingAndroid: purchase.autoRenewingAndroid ?? true, - basePlanIdAndroid: purchase.productId, - currentPlanId: purchase.productId, + basePlanIdAndroid: purchase.currentPlanId ?? purchase.productId, + currentPlanId: purchase.currentPlanId ?? purchase.productId, purchaseTokenAndroid: purchase.purchaseTokenAndroid ?? null, })); }, @@ -1555,9 +1564,7 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { return true; }, async restorePurchases(): Promise { - const purchases = await getAvailablePurchases({ - android: {includeSuspended: false}, - }); + const purchases = await getAvailablePurchases(); purchases.forEach(emitPurchaseUpdated); }, addPurchaseUpdatedListener(listener): number { diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index f52adcf15..648ed7ccf 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -82,8 +82,13 @@ const purchaseSafetyReleases = [ ['OpenIap.Maui 1.2.2', 'maui-iap-1.2.2'], ] as const; -const plannedFlutterPurchasePayloadReleases = [ +const plannedCrossSdkPurchasePayloadReleases = [ + 'openiap-google 2.5.1 (planned)', + 'react-native-iap 15.6.1 (planned)', + 'expo-iap 4.7.1 (planned)', 'flutter_inapp_purchase 9.6.1 (planned)', + 'kmp-iap 2.7.1 (planned)', + 'OpenIap.Maui 1.4.1 (planned)', ] as const; function Releases() { @@ -188,20 +193,24 @@ function Releases() { ), }, - // July 24, 2026 - Planned Flutter purchase payload patch + // July 24, 2026 - Planned cross-SDK native payload integrity patches { - id: 'flutter-purchase-payload-fix-planned-2026-07-24', + id: 'cross-sdk-payload-integrity-planned-2026-07-24', date: new Date('2026-07-24'), element: (
-