From ff2e397123cf83de49723a172fcaf57d7fa0d6ca Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 24 Jul 2026 22:16:37 +0900 Subject: [PATCH 01/16] fix(expo): preserve marketplace payloads Fail closed for unavailable Onside APIs and preserve Onside and Vega subscription metadata without routing through the wrong store runtime. --- libraries/expo-iap/ios/ExpoIapHelper.swift | 2 +- .../expo-iap/ios/onside/OnsideIapModule.swift | 99 +++++++++++++++++-- libraries/expo-iap/src/ExpoIapModule.ts | 40 ++++---- .../src/__tests__/ExpoIapModule.test.ts | 59 +++-------- .../__tests__/native-log-redaction.test.js | 44 +++++++++ .../src/__tests__/vega-adapter.test.ts | 53 ++++++++-- libraries/expo-iap/src/vega-adapter.ts | 26 ++--- 7 files changed, 222 insertions(+), 101 deletions(-) diff --git a/libraries/expo-iap/ios/ExpoIapHelper.swift b/libraries/expo-iap/ios/ExpoIapHelper.swift index 0b37b5213..74ffa47a3 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: diff --git a/libraries/expo-iap/ios/onside/OnsideIapModule.swift b/libraries/expo-iap/ios/onside/OnsideIapModule.swift index 38bce3f82..e7837cefa 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 { + subscriptionPeriodComponents($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 = subscriptionPeriodComponents(introductoryPrice.period) + dictionary["introductoryPriceAsAmountIOS"] = String(introductoryPrice.price.value) + dictionary["introductoryPriceIOS"] = formatPrice(introductoryPrice.price) + dictionary["introductoryPriceNumberOfPeriodsIOS"] = String(introductoryPeriod.value) + dictionary["introductoryPricePaymentModeIOS"] = + introductoryPrice.price.value == 0 ? "free-trial" : "empty" + dictionary["introductoryPriceSubscriptionPeriodIOS"] = introductoryPeriod.unit + } else if isSubscription { + dictionary["introductoryPricePaymentModeIOS"] = "empty" + } // 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 { + subscriptionPeriodComponents($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,29 @@ public final class ExpoIapOnsideModule: Module { NSDecimalNumber(string: String(product.price.value)) } + private func formatPrice(_ 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 subscriptionPeriodComponents(_ 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 +634,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__/native-log-redaction.test.js b/libraries/expo-iap/src/__tests__/native-log-redaction.test.js index 46b402af1..b5e612146 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,50 @@ 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).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..d17c713d5 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,18 @@ 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', }), ]); }); diff --git a/libraries/expo-iap/src/vega-adapter.ts b/libraries/expo-iap/src/vega-adapter.ts index d7a9fcffa..076ec7700 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; @@ -700,19 +701,20 @@ 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; return { id: receiptId, productId, transactionDate: toTimestamp(receipt.purchaseDate), purchaseToken: receiptId, + currentPlanId: type === 'subs' ? (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 +725,11 @@ function mapReceipt( obfuscatedAccountIdAndroid: null, obfuscatedProfileIdAndroid: null, developerPayloadAndroid: null, - isSuspendedAndroid: Boolean(receipt.isDeferred), + isSuspendedAndroid: false, + pendingPurchaseUpdateAndroid: + receipt.isDeferred && receipt.deferredSku + ? {products: [receipt.deferredSku], purchaseToken: receiptId} + : null, }; } @@ -956,16 +962,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 +1030,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 +1453,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, })); }, From 3263171f4c3b3966992dd5cec3cd8f864f44de86 Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 24 Jul 2026 22:16:53 +0900 Subject: [PATCH 02/16] fix(rn): preserve native purchase identity Carry explicit transaction identity and renewal metadata through Nitro while retaining Vega deferred plan updates and orderless Play semantics. --- .../java/com/margelo/nitro/iap/HybridRnIap.kt | 1 + .../react-native-iap/ios/RnIapHelper.swift | 5 +- .../src/__tests__/index.test.ts | 28 ++++++-- .../src/__tests__/utils/type-bridge.test.ts | 72 ++++++++++++++++++- .../src/__tests__/vega-adapter.test.ts | 34 +++++---- libraries/react-native-iap/src/index.ts | 4 ++ .../react-native-iap/src/specs/RnIap.nitro.ts | 1 + .../react-native-iap/src/utils/type-bridge.ts | 24 +++++-- .../react-native-iap/src/vega-adapter.ts | 34 ++++----- 9 files changed, 161 insertions(+), 42 deletions(-) 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..e9b74c677 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,14 +1129,16 @@ 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, }, @@ -1139,21 +1147,19 @@ describe('Amazon Vega adapter', () => { const module = createVegaIapModule(service); await expect( - module.getAvailablePurchases({ - android: {type: 'subs', includeSuspended: false}, - }), - ).resolves.toEqual([]); - - 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', }), ]); }); 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..9eff980eb 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 = [ @@ -712,33 +712,37 @@ 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; return { id: receiptId, + transactionId: receiptId, productId, transactionDate: toTimestamp(receipt.purchaseDate), purchaseToken: receiptId, - currentPlanId: type === 'subs' ? productId : null, + currentPlanId: type === 'subs' ? (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 && receipt.deferredSku + ? {products: [receipt.deferredSku], purchaseToken: receiptId} + : null, }; } @@ -1041,13 +1045,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 +1131,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 +1532,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, })); }, From 81f11dae87c01451695df358226263d284083225 Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 24 Jul 2026 22:18:40 +0900 Subject: [PATCH 03/16] fix(flutter): preserve verification payloads Preserve generated purchase and provider verification results, support Horizon verification, normalize nested bridge maps, and retain typed platform errors. --- .../AndroidInappPurchasePlugin.kt | 132 ++++++------------ .../FlutterInappPurchasePlugin.swift | 25 +--- .../flutter_inapp_purchase/lib/errors.dart | 5 +- .../lib/flutter_inapp_purchase.dart | 56 +++++--- .../flutter_inapp_purchase/lib/helpers.dart | 4 +- .../FlutterInappPurchasePlugin.swift | 24 +--- .../test/errors_unit_test.dart | 15 ++ ...pp_purchase_active_subscriptions_test.dart | 34 +++++ .../flutter_inapp_purchase_channel_test.dart | 96 ++++++++++++- .../test/helpers_unit_test.dart | 9 ++ .../test/iapkit_base_url_bridge_test.dart | 40 +++++- .../test/ios_methods_test.dart | 95 +++++++++++++ 12 files changed, 365 insertions(+), 170 deletions(-) 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 { From 48c8dda7cf3411d51bf2e297e9b0721c5a8db2a9 Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 24 Jul 2026 22:19:02 +0900 Subject: [PATCH 04/16] fix: preserve native purchase metadata Keep Google, Amazon, KMP, and MAUI purchase payloads faithful to their native sources, including subscription transitions, transaction identity, and listener diagnostics. --- libraries/kmp-iap/library/build.gradle.kts | 3 + .../kotlin/io/github/hyochan/kmpiap/Helper.kt | 26 ++- .../hyochan/kmpiap/InAppPurchaseAndroid.kt | 15 +- .../BillingPurchasePayloadMappingTest.kt | 64 ++++++ .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 75 +------ .../kmpiap/ProductPayloadNormalizerIOS.kt | 30 +++ .../kmpiap/ProductPayloadNormalizerTestIOS.kt | 103 ++++++++++ .../Platforms/Android/OpenIapAndroid.cs | 65 +++--- .../OpenIap.Maui.Tests/RecordJsonTests.cs | 193 +++++++++++++++++- .../java/dev/hyo/openiap/OpenIapModule.kt | 23 ++- .../AmazonSubscriptionGroupMappingTest.kt | 27 +++ .../BillingPurchasePayloadMappingTest.kt | 55 +++++ 12 files changed, 555 insertions(+), 124 deletions(-) create mode 100644 libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/BillingPurchasePayloadMappingTest.kt create mode 100644 packages/google/openiap/src/testPlay/java/dev/hyo/openiap/utils/BillingPurchasePayloadMappingTest.kt 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..87a84cbfd --- /dev/null +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/BillingPurchasePayloadMappingTest.kt @@ -0,0 +1,64 @@ +package io.github.hyochan.kmpiap + +import com.android.billingclient.api.Purchase as BillingPurchase +import io.github.hyochan.kmpiap.openiap.PurchaseAndroid +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("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..6212d6228 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,8 @@ package io.github.hyochan.kmpiap +import io.github.hyochan.kmpiap.openiap.PurchaseIOS +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 +34,34 @@ 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"] = 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" + } + return normalized +} + +internal fun decodePurchasePayloadIOS(data: Any?): PurchaseIOS? { + return runCatching { + val normalized = normalizePurchasePayloadIOS(data) ?: return@runCatching null + if (normalized["platform"] != "ios") return@runCatching null + PurchaseIOS.fromJson(normalized) + }.getOrNull() +} + 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..60d1fa499 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,105 @@ 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) + } } 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/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt index b7722dda4..6876cb408 100644 --- a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt @@ -58,6 +58,11 @@ private const val AMAZON_PRODUCT_DATA_BATCH_SIZE = 100 private const val AMAZON_PURCHASE_UPDATES_MAX_PAGES = 100 private const val AMAZON_EARLY_RESPONSE_CACHE_MAX = 128 +internal fun shouldIncludeAmazonReceipt( + isCanceled: Boolean, + hasCancelDate: Boolean, +): Boolean = !isCanceled && !hasCancelDate + internal fun configureAmazonPurchasingService( registerListener: () -> Unit, enablePendingPurchases: () -> Unit, @@ -304,9 +309,11 @@ internal fun buildAmazonPurchase( isCanceled: Boolean, isDeferred: Boolean, deferredSku: String? = null, + termSku: String? = null, productIdOverride: String? = null ): PurchaseAndroid { val resolvedProductId = productIdOverride?.takeIf { it.isNotBlank() } ?: receiptSku + val resolvedCurrentPlanId = termSku?.takeIf { it.isNotBlank() } ?: resolvedProductId val state = if (isCanceled) PurchaseState.Unknown else PurchaseState.Purchased val pendingSubscriptionUpdate = deferredSku ?.takeIf { isDeferred && it.isNotBlank() } @@ -321,24 +328,24 @@ internal fun buildAmazonPurchase( } return PurchaseAndroid( autoRenewingAndroid = isSubscription && !isCanceled, - currentPlanId = if (isSubscription) resolvedProductId else null, + currentPlanId = if (isSubscription) resolvedCurrentPlanId else null, dataAndroid = "", id = receiptId, ids = listOf(resolvedProductId), isAcknowledgedAndroid = null, isAutoRenewing = isSubscription && !isCanceled, + isSuspendedAndroid = false, packageNameAndroid = packageName, + pendingPurchaseUpdateAndroid = pendingSubscriptionUpdate, platform = IapPlatform.Android, productId = resolvedProductId, - pendingPurchaseUpdateAndroid = pendingSubscriptionUpdate, purchaseState = state, purchaseToken = receiptId, quantity = 1, signatureAndroid = null, store = IapStore.Amazon, transactionDate = purchaseDateMillis, - transactionId = receiptId, - isSuspendedAndroid = false + transactionId = receiptId ) } @@ -1302,7 +1309,12 @@ class OpenIapModule when (response.requestStatus) { PurchaseUpdatesResponse.RequestStatus.SUCCESSFUL -> { receipts += response.receipts.orEmpty() - .filter { it.cancelDate == null } + .filter { + shouldIncludeAmazonReceipt( + isCanceled = it.isCanceled, + hasCancelDate = it.cancelDate != null, + ) + } } PurchaseUpdatesResponse.RequestStatus.NOT_SUPPORTED -> { throw OpenIapError.FeatureNotSupported("Amazon Appstore IAP is not supported on this device") @@ -1662,6 +1674,7 @@ class OpenIapModule isCanceled = receiptCanceled, isDeferred = receiptDeferred, deferredSku = deferredSku, + termSku = termSku, productIdOverride = productIdOverride ).copy(dataAndroid = toJSON().toString()) } diff --git a/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonSubscriptionGroupMappingTest.kt b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonSubscriptionGroupMappingTest.kt index 5e57b8621..cdfdf98f8 100644 --- a/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonSubscriptionGroupMappingTest.kt +++ b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonSubscriptionGroupMappingTest.kt @@ -3,10 +3,33 @@ package dev.hyo.openiap import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class AmazonSubscriptionGroupMappingTest { + @Test + fun `available purchases reject both Amazon cancellation signals`() { + assertTrue( + shouldIncludeAmazonReceipt( + isCanceled = false, + hasCancelDate = false, + ), + ) + assertFalse( + shouldIncludeAmazonReceipt( + isCanceled = true, + hasCancelDate = false, + ), + ) + assertFalse( + shouldIncludeAmazonReceipt( + isCanceled = false, + hasCancelDate = true, + ), + ) + } + @Test fun `requested subscription skus stay isolated across multiple groups`() { val requestedSkuByReceiptId = mutableMapOf( @@ -106,11 +129,15 @@ class AmazonSubscriptionGroupMappingTest { isCanceled = false, isDeferred = true, deferredSku = "premium.yearly", + termSku = "premium.monthly", + productIdOverride = "premium", ) assertEquals(PurchaseState.Purchased, purchase.purchaseState) assertEquals(true, purchase.isAutoRenewing) assertFalse(purchase.isSuspendedAndroid ?: true) + assertEquals("premium", purchase.productId) + assertEquals("premium.monthly", purchase.currentPlanId) assertEquals( listOf("premium.yearly"), purchase.pendingPurchaseUpdateAndroid?.products, diff --git a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/utils/BillingPurchasePayloadMappingTest.kt b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/utils/BillingPurchasePayloadMappingTest.kt new file mode 100644 index 000000000..831f9a0bb --- /dev/null +++ b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/utils/BillingPurchasePayloadMappingTest.kt @@ -0,0 +1,55 @@ +package dev.hyo.openiap.utils + +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.Purchase as BillingPurchase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class BillingPurchasePayloadMappingTest { + private val originalJson = + """ + { + "orderId": "GPA.1234-5678", + "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() + + @Test + fun `preserves canonical Play purchase payload fields`() { + val billingPurchase = BillingPurchase(originalJson, "signature") + val purchase = with(BillingConverters) { + billingPurchase.toPurchase( + productType = BillingClient.ProductType.SUBS, + basePlanId = "monthly-base-plan", + ) + } + + assertEquals("monthly-base-plan", purchase.currentPlanId) + assertEquals(originalJson, purchase.dataAndroid) + assertEquals("developer-payload", purchase.developerPayloadAndroid) + assertEquals("GPA.1234-5678", purchase.transactionId) + assertEquals("signature", purchase.signatureAndroid) + assertEquals(2, purchase.quantity) + assertNotNull(purchase.pendingPurchaseUpdateAndroid) + val pendingUpdate = requireNotNull(purchase.pendingPurchaseUpdateAndroid) + assertEquals(listOf("premium.yearly"), pendingUpdate.products) + assertEquals("pending-token", pendingUpdate.purchaseToken) + } +} From f7328220aa524099e2ff20094828ab5361530c3e Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 24 Jul 2026 22:19:42 +0900 Subject: [PATCH 05/16] test: guard purchase payload parity Derive purchase-field expectations from generated SSOT and fail on canonical-order, alternative-store, or cross-wrapper payload drift. --- .../Example/tests/test_types_only.gd | 49 + scripts/audit-non-godot-parity.mjs | 217 +- scripts/audit-purchase-payload-parity.mjs | 2004 +++++++++++++++++ .../audit-purchase-payload-parity.test.mjs | 198 ++ 4 files changed, 2409 insertions(+), 59 deletions(-) create mode 100644 scripts/audit-purchase-payload-parity.mjs create mode 100644 scripts/audit-purchase-payload-parity.test.mjs 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/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 80885f33b..94311c132 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -7,6 +7,12 @@ import { GENERATED_SYNC_MANIFEST } from "../packages/gql/generated-sync-manifest import { collectGeneratedSyncDrift } from "../packages/gql/scripts/verify-generated-sync.mjs"; import { collectDeprecationScheduleDrift } from "./audit-deprecation-schedule.mjs"; import { assertSpecMatchesNativeFloor } from "./release-branch-policy.mjs"; +import { + collectPurchasePayloadParityFailures, + extractBalancedAfterMarker, + maskKotlinCommentsAndStrings, + maskTypeScriptCommentsAndStrings, +} from "./audit-purchase-payload-parity.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); execFileSync( @@ -25,6 +31,14 @@ execFileSync( ["--test", path.resolve(root, "scripts/audit-deprecation-schedule.test.mjs")], { stdio: "inherit" }, ); +execFileSync( + process.execPath, + [ + "--test", + path.resolve(root, "scripts/audit-purchase-payload-parity.test.mjs"), + ], + { stdio: "inherit" }, +); const failures = []; const EXPO_EXAMPLE_ROOT = "libraries/expo-iap/example"; @@ -233,6 +247,16 @@ function expectNoMatch(relativePath, regex, label = relativePath) { } } +function expectMatch(relativePath, regex, label = relativePath) { + expectFile(relativePath); + if (!exists(relativePath)) return; + const text = read(relativePath); + regex.lastIndex = 0; + if (!regex.test(text)) { + fail(`${label} must match ${regex}`); + } +} + function expectOptionalIncludes(relativePath, needles, label = relativePath) { if (!exists(relativePath)) return; expectIncludes(relativePath, needles, label); @@ -823,60 +847,6 @@ const GOOGLE_FLAVOR_MODULES = [ // `// (optional)` or `"a, b"`) that must not affect the structural // bracket-depth and argument-split scans below. Blank their contents out with // spaces, preserving indices, so only real code characters are scanned. -function maskKotlinCommentsAndStrings(text) { - let masked = ""; - let state = "code"; - let index = 0; - while (index < text.length) { - const char = text[index]; - const next = text[index + 1]; - if (state === "code") { - if (char === "/" && next === "/") { - state = "line"; - masked += " "; - index += 2; - continue; - } - if (char === "/" && next === "*") { - state = "block"; - masked += " "; - index += 2; - continue; - } - if (char === '"') state = "string"; - masked += char; - index += 1; - continue; - } - if (state === "line") { - if (char === "\n") state = "code"; - masked += char === "\n" ? "\n" : " "; - index += 1; - continue; - } - if (state === "block") { - if (char === "*" && next === "/") { - state = "code"; - masked += " "; - index += 2; - continue; - } - masked += char === "\n" ? "\n" : " "; - index += 1; - continue; - } - if (char === "\\") { - masked += " "; - index += 2; - continue; - } - if (char === '"') state = "code"; - masked += char === '"' ? '"' : " "; - index += 1; - } - return masked; -} - function parseHandlerBundleArgumentNames(text, bundleType, relativePath) { const source = maskKotlinCommentsAndStrings(text); const marker = new RegExp( @@ -1367,14 +1337,31 @@ function checkFlutter() { "getOnsideStorefront()", "OnsideEvent.subscriptionBillingIssue.rawValue", 'constants["ERROR_CODES"] = errorCodes', + "switch request.type ?? .inApp", + "return product.subscriptionPeriod != nil", + 'dictionary["type"] = isSubscription ? "subs" : "in-app"', + 'dictionary["environmentIOS"] = NSNull()', ], "Expo Onside root API bridge", ); expectNotIncludes( "libraries/expo-iap/ios/onside/OnsideIapModule.swift", - ["private let encoder: JSONEncoder"], + [ + "private let encoder: JSONEncoder", + 'dictionary["environmentIOS"] = transaction.storefront.id', + ], "Expo Onside unused encoder cleanup", ); + expectMatch( + "libraries/expo-iap/ios/onside/OnsideIapModule.swift", + /switch request\.type \?\? \.inApp\s*\{\s*case \.subs:\s*return product\.subscriptionPeriod != nil\s*case \.inApp:\s*return product\.subscriptionPeriod == nil\s*case \.all:\s*return true\s*\}/, + "Expo Onside product query type filter", + ); + expectIncludes( + "libraries/expo-iap/ios/ExpoIapHelper.swift", + ["else {\n return .inApp"], + "Expo iOS ProductRequest default", + ); expectIncludes( "libraries/expo-iap/src/index.ts", ["nativeModule.USING_ONSIDE_SDK", "nativeModule.restorePurchases"], @@ -1386,16 +1373,75 @@ function checkFlutter() { "const ONSIDE_MARKETPLACE_ID = 'com.onside.marketplace-app'", "shouldUseOnsideModule()", "onsideModuleUnavailable", - "return getExpoIapFallbackModule()?.[prop]", + "The call was not routed through Apple StoreKit.", ], "Expo Onside native module proxy routing", ); + expectNotIncludes( + "libraries/expo-iap/src/ExpoIapModule.ts", + ["getExpoIapFallbackModule", "expoIapFallback"], + "Expo Onside must not silently route unsupported operations through Apple StoreKit", + ); + expectMatch( + "libraries/expo-iap/src/ExpoIapModule.ts", + /if\s*\(\s*value !== undefined \|\| resolved\.name !== ['"]ExpoIapOnside['"]\s*\)\s*\{\s*return value;\s*\}\s*return \(\) => \{\s*throw new UnavailabilityError\(/, + "Expo Onside missing methods must fail closed", + ); + expectNoMatch( + "libraries/expo-iap/src/ExpoIapModule.ts", + /requireNativeModule\(\s*['"]ExpoIap['"]\s*\)\s*\[\s*prop\s*\]/, + "Expo Onside missing methods must not fall back to Apple StoreKit", + ); + const expoModulePath = "libraries/expo-iap/src/ExpoIapModule.ts"; + const expoModuleSource = read(expoModulePath); + const onsideBranch = extractBalancedAfterMarker( + expoModuleSource, + "if (shouldUseOnsideModule())", + "{", + "}", + `${expoModulePath} Onside selection branch`, + maskTypeScriptCommentsAndStrings, + ); + if (onsideBranch) { + if ( + /requireNativeModule\(\s*['"]ExpoIap['"]\s*\)|name\s*:\s*['"]ExpoIap['"]/.test( + onsideBranch.body, + ) + ) { + fail( + `${expoModulePath} Onside selection branch must never return the Apple StoreKit module`, + ); + } + const missingModuleCatch = extractBalancedAfterMarker( + onsideBranch.body, + "catch (error)", + "{", + "}", + `${expoModulePath} Onside missing-module catch`, + maskTypeScriptCommentsAndStrings, + ); + if (missingModuleCatch) { + const maskedCatch = maskTypeScriptCommentsAndStrings( + missingModuleCatch.body, + ); + if ( + /\breturn\b/.test(maskedCatch) || + !/onsideModuleUnavailable\s*=\s*true\s*;\s*throw new UnavailabilityError\s*\(/.test( + maskedCatch, + ) + ) { + fail( + `${expoModulePath} missing Onside module must mark unavailable and throw without fallback`, + ); + } + } + } expectIncludes( "libraries/expo-iap/src/__tests__/ExpoIapModule.test.ts", [ "re-resolves when Onside availability changes after initial access", - "does not repeatedly load a missing ExpoIapOnside module", - "surfaces non-missing ExpoIap fallback errors", + "fails closed without repeatedly loading a missing ExpoIapOnside module", + "fails closed for methods missing from ExpoIapOnside", ], "Expo Onside module proxy tests", ); @@ -1771,6 +1817,51 @@ function checkMaui() { ["JsonObjectToDictionary"], "MAUI iOS JSON bridge", ); + const mauiAndroidPath = + "libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.cs"; + for (const [listener, variable, type, eventName] of [ + ["PurchaseUpdated", "purchase", "Purchase", "purchaseUpdated"], + [ + "SubscriptionBillingIssue", + "purchase", + "Purchase", + "subscriptionBillingIssue", + ], + [ + "UserChoiceBillingAndroid", + "details", + "UserChoiceBillingDetails", + "userChoiceBillingAndroid", + ], + [ + "DeveloperProvidedBillingAndroid", + "details", + "DeveloperProvidedBillingDetailsAndroid", + "developerProvidedBillingAndroid", + ], + ]) { + expectMatch( + mauiAndroidPath, + new RegExp( + `_module\\.Add${listener}Listener\\(new EventBridge\\(json =>\\s*\\{\\s*var ${variable} = DeserializeListenerPayload<${type}>\\(\\s*json,\\s*"${eventName}"\\s*\\);`, + ), + `MAUI Android ${eventName} listener payload bridge`, + ); + } + expectMatch( + mauiAndroidPath, + /_module\.AddPurchaseErrorListener\(new EventBridge\(json =>\s*\{\s*_purchaseError\.OnNext\(OpenIapErrorMapper\.FromJson\(json\)\);/, + "MAUI Android purchaseError listener payload bridge", + ); + expectIncludes( + mauiAndroidPath, + [ + "private static T? DeserializeListenerPayload", + "ReportListenerFailure(listenerName, ex)", + "Do not include the raw payload", + ], + "MAUI Android listener failure visibility", + ); } function checkNativeApis() { @@ -2398,7 +2489,12 @@ function checkBillingChoiceFieldBindings() { ); expectIncludes( "libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.cs", - ["JsonSerializer.Deserialize"], + [ + "DeserializeListenerPayload", + "JsonSerializer.Deserialize(json, JsonOptions.Default)", + "catch (JsonException ex)", + "catch (NotSupportedException ex)", + ], "MAUI Billing Choice event payload", ); @@ -6130,6 +6226,9 @@ checkDeprecationSchedule(); checkExpoSsotRegistry(); checkE2eExampleIds(); checkGeneratedTypeSync(); +for (const issue of collectPurchasePayloadParityFailures(root)) { + fail(issue); +} checkGqlRuntimeExports(); checkOperationRegistry(); checkGoogleFlavorHandlerWiring(); diff --git a/scripts/audit-purchase-payload-parity.mjs b/scripts/audit-purchase-payload-parity.mjs new file mode 100644 index 000000000..032bd958c --- /dev/null +++ b/scripts/audit-purchase-payload-parity.mjs @@ -0,0 +1,2004 @@ +import fs from "node:fs"; +import path from "node:path"; + +// Purchase payload fields are discovered from generated native/GQL models. +// This audit intentionally keeps only transport-only fields and documented +// store defaults as local exceptions, so schema growth cannot be hidden by a +// duplicated hand-written field inventory. +let root = ""; +let failures = []; + +function abs(relativePath) { + return path.join(root, relativePath); +} + +function read(relativePath) { + return fs.readFileSync(abs(relativePath), "utf8"); +} + +function exists(relativePath) { + return fs.existsSync(abs(relativePath)); +} + +function fail(message) { + failures.push(message); +} + +function expectFile(relativePath) { + if (!exists(relativePath)) fail(`missing file: ${relativePath}`); +} + +function expectIncludes(relativePath, needles, label = relativePath) { + expectFile(relativePath); + if (!exists(relativePath)) return; + const text = read(relativePath); + for (const needle of needles) { + if (!text.includes(needle)) { + fail(`${label} is missing ${JSON.stringify(needle)}`); + } + } +} + +function expectSameSet(label, expected, actual) { + const expectedSet = new Set(expected); + const actualSet = new Set(actual); + const missing = [...expectedSet].filter((entry) => !actualSet.has(entry)); + const extra = [...actualSet].filter((entry) => !expectedSet.has(entry)); + if (missing.length > 0 || extra.length > 0) { + fail( + `${label} mismatch (missing: ${missing.join(", ") || "none"}; extra: ${extra.join(", ") || "none"})`, + ); + } +} + +function uniqueMatches(text, regex, group = 1) { + regex.lastIndex = 0; + return [...new Set([...text.matchAll(regex)].map((match) => match[group]))] + .filter(Boolean) + .sort(); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function maskKotlinCommentsAndStrings(text) { + let masked = ""; + let state = "code"; + let index = 0; + while (index < text.length) { + const char = text[index]; + const next = text[index + 1]; + if (state === "code") { + if (char === "/" && next === "/") { + state = "line"; + masked += " "; + index += 2; + continue; + } + if (char === "/" && next === "*") { + state = "block"; + masked += " "; + index += 2; + continue; + } + if (char === '"') state = "string"; + masked += char; + index += 1; + continue; + } + if (state === "line") { + if (char === "\n") state = "code"; + masked += char === "\n" ? "\n" : " "; + index += 1; + continue; + } + if (state === "block") { + if (char === "*" && next === "/") { + state = "code"; + masked += " "; + index += 2; + continue; + } + masked += char === "\n" ? "\n" : " "; + index += 1; + continue; + } + if (char === "\\") { + masked += " "; + index += 2; + continue; + } + if (char === '"') state = "code"; + masked += char === '"' ? '"' : " "; + index += 1; + } + return masked; +} + +// Dart uses both single- and double-quoted strings. Mask both while preserving +// indices so structural scans cannot be confused by delimiters in literals. +function maskDartCommentsAndStrings(text) { + let masked = ""; + let state = "code"; + let quote = ""; + let index = 0; + while (index < text.length) { + const char = text[index]; + const next = text[index + 1]; + if (state === "code") { + if (char === "/" && next === "/") { + state = "line"; + masked += " "; + index += 2; + continue; + } + if (char === "/" && next === "*") { + state = "block"; + masked += " "; + index += 2; + continue; + } + if (char === "'" || char === '"') { + state = "string"; + quote = char; + } + masked += char; + index += 1; + continue; + } + if (state === "line") { + if (char === "\n") state = "code"; + masked += char === "\n" ? "\n" : " "; + index += 1; + continue; + } + if (state === "block") { + if (char === "*" && next === "/") { + state = "code"; + masked += " "; + index += 2; + continue; + } + masked += char === "\n" ? "\n" : " "; + index += 1; + continue; + } + if (char === "\\") { + masked += " "; + index += 2; + continue; + } + if (char === quote) { + state = "code"; + masked += char; + } else { + masked += char === "\n" ? "\n" : " "; + } + index += 1; + } + return masked; +} + +function maskTypeScriptCommentsAndStrings(text) { + let masked = ""; + let state = "code"; + let quote = ""; + let index = 0; + while (index < text.length) { + const char = text[index]; + const next = text[index + 1]; + if (state === "code") { + if (char === "/" && next === "/") { + state = "line"; + masked += " "; + index += 2; + continue; + } + if (char === "/" && next === "*") { + state = "block"; + masked += " "; + index += 2; + continue; + } + if (char === "'" || char === '"' || char === "`") { + state = "string"; + quote = char; + } + masked += char; + index += 1; + continue; + } + if (state === "line") { + if (char === "\n") state = "code"; + masked += char === "\n" ? "\n" : " "; + index += 1; + continue; + } + if (state === "block") { + if (char === "*" && next === "/") { + state = "code"; + masked += " "; + index += 2; + continue; + } + masked += char === "\n" ? "\n" : " "; + index += 1; + continue; + } + if (char === "\\") { + masked += " "; + index += 2; + continue; + } + if (char === quote) { + state = "code"; + masked += char; + } else { + masked += char === "\n" ? "\n" : " "; + } + index += 1; + } + return masked; +} + +function extractBalancedAfterMarker( + text, + marker, + open, + close, + label, + mask = maskKotlinCommentsAndStrings, +) { + const masked = mask(text); + const markerIndex = masked.indexOf(marker); + if (markerIndex < 0) { + fail(`${label} is missing ${JSON.stringify(marker)}`); + return null; + } + + const openIndex = masked.indexOf(open, markerIndex + marker.length); + if (openIndex < 0) { + fail(`${label} is missing ${open} after ${JSON.stringify(marker)}`); + return null; + } + + let depth = 1; + let index = openIndex + 1; + while (index < masked.length && depth > 0) { + if (masked[index] === open) depth += 1; + else if (masked[index] === close) depth -= 1; + index += 1; + } + if (depth !== 0) { + fail(`${label} has an unbalanced ${open}${close} block`); + return null; + } + + return { + body: text.slice(openIndex + 1, index - 1), + end: index, + start: openIndex, + }; +} + +function extractDartFunctionBody(text, marker, label) { + return extractFunctionBody(text, marker, label, maskDartCommentsAndStrings); +} + +function extractTypeScriptFunctionBody(text, marker, label) { + return extractFunctionBody( + text, + marker, + label, + maskTypeScriptCommentsAndStrings, + ); +} + +function extractFunctionBody( + text, + marker, + label, + mask = maskKotlinCommentsAndStrings, +) { + const parameters = extractBalancedAfterMarker( + text, + marker, + "(", + ")", + label, + mask, + ); + if (!parameters) return null; + + const suffix = text.slice(parameters.end); + return extractBalancedAfterMarker(suffix, "", "{", "}", label, mask); +} + +function splitTopLevelSegments(text, mask = maskDartCommentsAndStrings) { + const masked = mask(text); + const segments = []; + let depth = 0; + let start = 0; + for (let index = 0; index < masked.length; index += 1) { + const char = masked[index]; + if (char === "(" || char === "{" || char === "[") depth += 1; + else if (char === ")" || char === "}" || char === "]") depth -= 1; + else if (char === "," && depth === 0) { + segments.push(text.slice(start, index).trim()); + start = index + 1; + } + } + const tail = text.slice(start).trim(); + if (tail.length > 0) segments.push(tail); + return segments.filter(Boolean); +} + +function parseKotlinToJsonKeys(text, type, relativePath) { + const classBlock = extractBalancedAfterMarker( + text, + `public data class ${type}(`, + "{", + "}", + `${relativePath} ${type}`, + ); + if (!classBlock) return []; + + const mapBlock = extractBalancedAfterMarker( + classBlock.body, + "override fun toJson(): Map = mapOf", + "(", + ")", + `${relativePath} ${type}.toJson`, + ); + if (!mapBlock) return []; + + return [ + ...new Set( + splitTopLevelSegments(mapBlock.body, maskKotlinCommentsAndStrings) + .map((segment) => segment.match(/^\s*"([^"]+)"\s+to\b/)?.[1]) + .filter((key) => key && key !== "__typename"), + ), + ].sort(); +} + +function parseSwiftStoredPropertyNames(text, type, relativePath) { + const classBlock = extractBalancedAfterMarker( + text, + `public struct ${type}:`, + "{", + "}", + `${relativePath} ${type}`, + ); + if (!classBlock) return []; + + return uniqueMatches( + classBlock.body, + /^\s*public var ([A-Za-z][A-Za-z0-9_]*)\s*:/gm, + ); +} + +function parseDartFromJsonKeys(text, type, relativePath) { + const classBlock = extractBalancedAfterMarker( + text, + `class ${type} `, + "{", + "}", + `${relativePath} ${type}`, + maskDartCommentsAndStrings, + ); + if (!classBlock) return []; + + const factoryBlock = extractBalancedAfterMarker( + classBlock.body, + `factory ${type}.fromJson`, + "{", + "}", + `${relativePath} ${type}.fromJson`, + maskDartCommentsAndStrings, + ); + if (!factoryBlock) return []; + + return uniqueMatches(factoryBlock.body, /json\['([^']+)'\]/g); +} + +function parseDartConstructorArgumentNames( + text, + type, + relativePath, + functionName, +) { + const constructor = extractBalancedAfterMarker( + text, + `return gentype.${type}`, + "(", + ")", + `${relativePath} ${functionName} ${type}`, + maskDartCommentsAndStrings, + ); + if (!constructor) return []; + + return splitTopLevelSegments(constructor.body, maskDartCommentsAndStrings) + .map((segment) => segment.match(/^([A-Za-z][A-Za-z0-9_]*)\s*:/)?.[1]) + .filter(Boolean) + .sort(); +} + +function parseTypeScriptInterfaceFieldNames(text, type, relativePath) { + const interfaceBlock = extractBalancedAfterMarker( + text, + `export interface ${type} `, + "{", + "}", + `${relativePath} ${type}`, + maskTypeScriptCommentsAndStrings, + ); + if (!interfaceBlock) return []; + + return uniqueMatches( + interfaceBlock.body, + /^\s*(?:readonly\s+)?([A-Za-z][A-Za-z0-9_]*)\??\s*:/gm, + ); +} + +function parseNamedCallArguments( + text, + marker, + separator, + label, + mask = maskKotlinCommentsAndStrings, +) { + const constructor = extractBalancedAfterMarker( + text, + marker, + "(", + ")", + label, + mask, + ); + if (!constructor) return new Map(); + + const entries = new Map(); + for (const segment of splitTopLevelSegments(constructor.body, mask)) { + const masked = mask(segment); + const codeStart = masked.search(/\S/); + const code = codeStart < 0 ? "" : segment.slice(codeStart); + const match = code.match( + new RegExp( + `^([A-Za-z][A-Za-z0-9_]*)\\s*${escapeRegExp(separator)}\\s*([\\s\\S]+)$`, + ), + ); + if (!match) { + fail(`${label} has an unrecognized named argument: ${code}`); + continue; + } + if (entries.has(match[1])) { + fail(`${label} passes ${match[1]} more than once`); + continue; + } + entries.set(match[1], match[2]); + } + return entries; +} + +function parseNamedCallArgumentNames( + text, + marker, + separator, + label, + mask = maskKotlinCommentsAndStrings, +) { + return [ + ...parseNamedCallArguments(text, marker, separator, label, mask).keys(), + ].sort(); +} + +function normalizeExpression(expression) { + return expression.replace(/\s+/g, " ").trim(); +} + +function expectNamedExpression(entries, field, pattern, label) { + const expression = normalizeExpression(entries.get(field) ?? ""); + if (!pattern.test(expression)) { + fail( + `${label}.${field} has unexpected source expression ${JSON.stringify(expression)}`, + ); + } +} + +function expectMappedGeneratedFields( + label, + generatedFields, + entries, + intentionallyDefaultedFields = [], +) { + const generated = new Set(generatedFields); + const unknownDefaults = intentionallyDefaultedFields + .filter((field) => !generated.has(field)) + .sort(); + if (unknownDefaults.length > 0) { + fail( + `${label} declares unknown defaulted fields: ${unknownDefaults.join(", ")}`, + ); + } + + const defaulted = new Set(intentionallyDefaultedFields); + expectSameSet( + label, + generatedFields.filter((field) => !defaulted.has(field)), + [...entries.keys()], + ); +} + +function parseTypeScriptObjectEntries(text, marker, label) { + const object = extractBalancedAfterMarker( + text, + marker, + "{", + "}", + label, + maskTypeScriptCommentsAndStrings, + ); + if (!object) return new Map(); + + const entries = new Map(); + for (const segment of splitTopLevelSegments( + object.body, + maskTypeScriptCommentsAndStrings, + )) { + const masked = maskTypeScriptCommentsAndStrings(segment); + const codeStart = masked.search(/\S/); + const code = codeStart < 0 ? "" : segment.slice(codeStart); + const explicit = code.match(/^([A-Za-z][A-Za-z0-9_]*)\s*:\s*([\s\S]+)$/); + const shorthand = code.match(/^([A-Za-z][A-Za-z0-9_]*)$/); + const name = explicit?.[1] ?? shorthand?.[1]; + if (!name) { + fail(`${label} has an unrecognized object field: ${code}`); + continue; + } + if (entries.has(name)) { + fail(`${label} sets ${name} more than once`); + continue; + } + entries.set(name, explicit?.[2] ?? name); + } + return entries; +} + +function parseTypeScriptObjectFieldNames(text, marker, label) { + return [...parseTypeScriptObjectEntries(text, marker, label).keys()].sort(); +} + +function parseDartMapEntries(mapBody, label) { + const entries = new Map(); + for (const segment of splitTopLevelSegments( + mapBody, + maskDartCommentsAndStrings, + )) { + const masked = maskDartCommentsAndStrings(segment); + const codeStart = masked.search(/\S/); + const code = codeStart < 0 ? "" : segment.slice(codeStart); + if (code.startsWith("...")) continue; + const match = code.match(/^'([^']+)'\s*:\s*([\s\S]+)$/); + if (!match) { + fail(`${label} has an unrecognized top-level entry: ${code}`); + continue; + } + if (entries.has(match[1])) { + fail(`${label} overrides ${match[1]} more than once`); + continue; + } + entries.set(match[1], match[2]); + } + return entries; +} + +function hasDominatingOperator(prefix) { + return /\?\?|\?|&&|\|\|/.test(prefix); +} + +function validateCanonicalOrLegacyHelper(source, label) { + const helper = extractDartFunctionBody( + source, + "dynamic _canonicalOrLegacy", + `${label} _canonicalOrLegacy`, + ); + if (!helper) return false; + + const masked = maskDartCommentsAndStrings(helper.body); + const canonicalRead = + /\bfinal\s+canonical\s*=\s*payload\s*\[\s*canonicalKey\s*\]\s*;/.exec( + masked, + ); + const canonicalGuard = + /\bif\s*\(\s*canonical\s*!=\s*null\s*\)\s*\{\s*return\s+canonical\s*;\s*\}/.exec( + masked, + ); + const legacyRead = + /\bfinal\s+legacy\s*=\s*payload\s*\[\s*legacyKey\s*\]\s*;/.exec(masked); + const legacyReturn = /\breturn\s+legacy\s*;/.exec(masked); + const ordered = + canonicalRead && + canonicalGuard && + legacyRead && + legacyReturn && + canonicalRead.index < canonicalGuard.index && + canonicalGuard.index < legacyRead.index && + legacyRead.index < legacyReturn.index; + + const payloadReads = [...masked.matchAll(/\bpayload\s*\[\s*(\w+)\s*\]/g)].map( + (match) => match[1], + ); + const exactReads = + payloadReads.length === 2 && + payloadReads[0] === "canonicalKey" && + payloadReads[1] === "legacyKey"; + const returnValues = [ + ...masked.matchAll(/\breturn\s+([A-Za-z][A-Za-z0-9_]*)\s*;/g), + ].map((match) => match[1]); + const exactReturns = + returnValues.length === 2 && + returnValues[0] === "canonical" && + returnValues[1] === "legacy"; + + if (!ordered || !exactReads || !exactReturns) { + fail( + `${label} _canonicalOrLegacy must return payload[canonicalKey] before consulting payload[legacyKey]`, + ); + return false; + } + return true; +} + +function canonicalKeyFromHelperCall(expression) { + const masked = maskDartCommentsAndStrings(expression); + const markerIndex = masked.indexOf("_canonicalOrLegacy"); + if (markerIndex < 0 || hasDominatingOperator(masked.slice(0, markerIndex))) { + return null; + } + + const call = extractBalancedAfterMarker( + expression, + "_canonicalOrLegacy", + "(", + ")", + "Flutter _canonicalOrLegacy call", + maskDartCommentsAndStrings, + ); + if (!call) return null; + const segments = splitTopLevelSegments(call.body, maskDartCommentsAndStrings); + if (segments[0]?.trim() !== "sourcePayload") return null; + + const canonicalKey = segments + .slice(1) + .map((segment) => + segment.match(/^\s*canonicalKey\s*:\s*['"]([^'"]+)['"]\s*$/), + ) + .find(Boolean)?.[1]; + return canonicalKey ?? null; +} + +function firstCanonicalSourceReference( + expression, + functionBody, + canonicalHelperIsValid, + seenIdentifiers = new Set(), +) { + if (canonicalHelperIsValid) { + const helperKey = canonicalKeyFromHelperCall(expression); + if (helperKey) return helperKey; + } + + const seenInExpression = new Set(); + const references = + /sourcePayload\s*\[\s*['"]([^'"]+)['"]\s*\]|\b([A-Za-z][A-Za-z0-9_]*)\b/g; + for (const reference of expression.matchAll(references)) { + if (reference[1]) { + return hasDominatingOperator(expression.slice(0, reference.index)) + ? null + : reference[1]; + } + const identifier = reference[2]; + if (seenInExpression.has(identifier)) continue; + seenInExpression.add(identifier); + if (seenIdentifiers.has(identifier)) continue; + const declaration = functionBody.match( + new RegExp( + `(?:final|var)\\s+(?:[A-Za-z][A-Za-z0-9_<>?, ]*\\s+)?${escapeRegExp(identifier)}\\s*=([\\s\\S]*?);`, + ), + ); + const nextSeen = new Set([...seenIdentifiers, identifier]); + if (declaration) { + const sourceKey = firstCanonicalSourceReference( + declaration[1], + functionBody, + canonicalHelperIsValid, + nextSeen, + ); + if (sourceKey) { + return hasDominatingOperator(expression.slice(0, reference.index)) + ? null + : sourceKey; + } + } + + const assignments = new RegExp( + `\\b${escapeRegExp(identifier)}\\s*=(?!=)([\\s\\S]*?);`, + "g", + ); + for (const assignment of functionBody.matchAll(assignments)) { + const sourceKey = firstCanonicalSourceReference( + assignment[1], + functionBody, + canonicalHelperIsValid, + nextSeen, + ); + if (sourceKey) { + return hasDominatingOperator(expression.slice(0, reference.index)) + ? null + : sourceKey; + } + } + } + return null; +} + +function firstNitroPurchaseSourceReference( + expression, + functionBody, + seenIdentifiers = new Set(), +) { + const seenInExpression = new Set(); + const references = + /nitroPurchase\.([A-Za-z][A-Za-z0-9_]*)|\b([A-Za-z][A-Za-z0-9_]*)\b/g; + for (const reference of expression.matchAll(references)) { + if (reference[1]) { + return hasDominatingOperator(expression.slice(0, reference.index)) + ? null + : reference[1]; + } + const identifier = reference[2]; + if (seenInExpression.has(identifier)) continue; + seenInExpression.add(identifier); + if (seenIdentifiers.has(identifier)) continue; + const declaration = functionBody.match( + new RegExp( + `(?:const|let|var)\\s+(?:[A-Za-z][A-Za-z0-9_<>?, |\\[\\]]*\\s+)?${escapeRegExp(identifier)}\\s*=([\\s\\S]*?);`, + ), + ); + if (!declaration) continue; + const sourceKey = firstNitroPurchaseSourceReference( + declaration[1], + functionBody, + new Set([...seenIdentifiers, identifier]), + ); + if (sourceKey) { + return hasDominatingOperator(expression.slice(0, reference.index)) + ? null + : sourceKey; + } + } + return null; +} + +function typeScriptMemberReferences( + expression, + rootIdentifier, + functionBody, + seenIdentifiers = new Set(), +) { + const masked = maskTypeScriptCommentsAndStrings(expression); + const references = new Set(); + const pattern = new RegExp( + `\\b${escapeRegExp(rootIdentifier)}(?:\\.|\\?\\.)([A-Za-z][A-Za-z0-9_]*)`, + "g", + ); + for (const match of masked.matchAll(pattern)) { + references.add(match[1]); + } + if (!functionBody) return references; + + for (const match of masked.matchAll(/\b([A-Za-z][A-Za-z0-9_]*)\b/g)) { + const identifier = match[1]; + if ( + identifier === rootIdentifier || + seenIdentifiers.has(identifier) || + references.has(identifier) + ) { + continue; + } + const declaration = functionBody.match( + new RegExp( + `(?:const|let|var)\\s+${escapeRegExp(identifier)}(?:\\s*:[^=;]+)?\\s*=([\\s\\S]*?);`, + ), + ); + if (!declaration) continue; + const nested = typeScriptMemberReferences( + declaration[1], + rootIdentifier, + functionBody, + new Set([...seenIdentifiers, identifier]), + ); + for (const field of nested) references.add(field); + } + return references; +} + +function nestedTypeScriptMemberReferences( + expression, + rootIdentifier, + parentField, +) { + const masked = maskTypeScriptCommentsAndStrings(expression); + const references = new Set(); + const pattern = new RegExp( + `\\b${escapeRegExp(rootIdentifier)}(?:\\.|\\?\\.)${escapeRegExp(parentField)}(?:\\.|\\?\\.)([A-Za-z][A-Za-z0-9_]*)`, + "g", + ); + for (const match of masked.matchAll(pattern)) { + references.add(match[1]); + } + return references; +} + +function expectOnlySourceReferences(actual, allowed, label) { + const unexpected = [...actual].filter((field) => !allowed.has(field)).sort(); + if (unexpected.length > 0) { + fail(`${label} reads unexpected source fields: ${unexpected.join(", ")}`); + } +} + +function firstKotlinPurchaseSourceReference(expression) { + return expression.match( + /\b(?:androidPurchase|purchase)(?:\?)?\.([A-Za-z][A-Za-z0-9_]*)/, + )?.[1]; +} + +function swiftDictionarySourceReferences(expression) { + const references = new Set(); + let index = 0; + let state = "code"; + while (index < expression.length) { + const char = expression[index]; + const next = expression[index + 1]; + if (state === "code") { + if (char === "/" && next === "/") { + state = "line"; + index += 2; + continue; + } + if (char === "/" && next === "*") { + state = "block"; + index += 2; + continue; + } + if (char === '"') { + state = "string"; + index += 1; + continue; + } + if (expression.startsWith("dictionary", index)) { + const suffix = expression.slice(index); + const match = suffix.match( + /^dictionary\s*\[\s*"([A-Za-z][A-Za-z0-9_]*)"\s*\]/, + ); + if (match) { + references.add(match[1]); + index += match[0].length; + continue; + } + } + index += 1; + continue; + } + if (state === "line") { + if (char === "\n") state = "code"; + index += 1; + continue; + } + if (state === "block") { + if (char === "*" && next === "/") { + state = "code"; + index += 2; + } else { + index += 1; + } + continue; + } + if (state === "string") { + if (char === "\\") { + index += 2; + } else if (char === '"') { + state = "code"; + index += 1; + } else { + index += 1; + } + } + } + return references; +} + +function firstSwiftDictionarySourceReference(expression, functionBody) { + const direct = expression.match( + /dictionary\s*\[\s*"([A-Za-z][A-Za-z0-9_]*)"\s*\]/, + ); + if (direct) return direct[1]; + + const identifier = expression.trim().match(/^([A-Za-z][A-Za-z0-9_]*)$/)?.[1]; + if (!identifier) return null; + const declarationIndex = functionBody.search( + new RegExp(`\\b(?:let|var)\\s+${escapeRegExp(identifier)}\\b`), + ); + if (declarationIndex < 0) return null; + return functionBody + .slice(declarationIndex) + .match(/dictionary\s*\[\s*"([A-Za-z][A-Za-z0-9_]*)"\s*\]/)?.[1]; +} + +function checkFlutterPayloadContracts() { + const kotlinPath = + "packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt"; + const swiftPath = "packages/apple/Sources/Models/Types.swift"; + const dartTypesPath = "packages/gql/src/generated/types.dart"; + const flutterHelpersPath = + "libraries/flutter_inapp_purchase/lib/helpers.dart"; + for (const relativePath of [ + kotlinPath, + swiftPath, + dartTypesPath, + flutterHelpersPath, + ]) { + expectFile(relativePath); + } + if ( + !exists(kotlinPath) || + !exists(swiftPath) || + !exists(dartTypesPath) || + !exists(flutterHelpersPath) + ) { + return; + } + + const kotlin = read(kotlinPath); + const swift = read(swiftPath); + const dartTypes = read(dartTypesPath); + const helpers = read(flutterHelpersPath); + const canonicalHelperIsValid = validateCanonicalOrLegacyHelper( + helpers, + flutterHelpersPath, + ); + const purchaseFields = { + PurchaseAndroid: parseKotlinToJsonKeys( + kotlin, + "PurchaseAndroid", + kotlinPath, + ), + PurchaseIOS: parseSwiftStoredPropertyNames(swift, "PurchaseIOS", swiftPath), + }; + + // Flutter intentionally exposes this transport-only field in addition to the + // generated native purchase payloads. + const flutterOnlyPurchaseFields = ["isAlternativeBilling"]; + for (const [type, nativeFields] of Object.entries(purchaseFields)) { + expectSameSet( + `Flutter ${type} decoder fields`, + [...nativeFields, ...flutterOnlyPurchaseFields], + parseDartFromJsonKeys(dartTypes, type, dartTypesPath), + ); + } + + const productFields = { + ProductAndroid: parseKotlinToJsonKeys(kotlin, "ProductAndroid", kotlinPath), + ProductSubscriptionAndroid: parseKotlinToJsonKeys( + kotlin, + "ProductSubscriptionAndroid", + kotlinPath, + ), + ProductIOS: parseSwiftStoredPropertyNames(swift, "ProductIOS", swiftPath), + ProductSubscriptionIOS: parseSwiftStoredPropertyNames( + swift, + "ProductSubscriptionIOS", + swiftPath, + ), + }; + const productFunction = extractDartFunctionBody( + helpers, + "gentype.ProductCommon parseProductFromNative", + `${flutterHelpersPath} parseProductFromNative`, + ); + if (productFunction) { + for (const [type, nativeFields] of Object.entries(productFields)) { + expectSameSet( + `Flutter parseProductFromNative ${type} fields`, + nativeFields, + parseDartConstructorArgumentNames( + productFunction.body, + type, + flutterHelpersPath, + "parseProductFromNative", + ), + ); + } + } + + const convertFunction = extractDartFunctionBody( + helpers, + "gentype.Purchase convertToPurchase", + `${flutterHelpersPath} convertToPurchase`, + ); + if (!convertFunction) return; + + const sourceMap = extractBalancedAfterMarker( + convertFunction.body, + "final sourcePayload = normalizeDynamicMap(", + "{", + "}", + `${flutterHelpersPath} canonical sourcePayload`, + maskDartCommentsAndStrings, + ); + if (!sourceMap) return; + const sourceSegments = splitTopLevelSegments( + sourceMap.body, + maskDartCommentsAndStrings, + ); + const originalIndex = sourceSegments.findIndex((segment) => + segment.includes("...originalJson"), + ); + const itemIndex = sourceSegments.findIndex((segment) => + segment.includes("...itemJson"), + ); + if ( + originalIndex < 0 || + itemIndex < 0 || + originalIndex >= itemIndex || + itemIndex !== sourceSegments.length - 1 + ) { + fail( + `${flutterHelpersPath} sourcePayload must merge legacy originalJson first and canonical itemJson last`, + ); + } + + const mapBlocks = []; + const mapMarker = "final map = "; + let offset = 0; + while (offset < convertFunction.body.length) { + const markerIndex = convertFunction.body.indexOf(mapMarker, offset); + if (markerIndex < 0) break; + const suffix = convertFunction.body.slice(markerIndex); + const mapBlock = extractBalancedAfterMarker( + suffix, + mapMarker, + "{", + "}", + `${flutterHelpersPath} convertToPurchase map`, + maskDartCommentsAndStrings, + ); + if (!mapBlock) break; + mapBlocks.push(mapBlock.body); + offset = markerIndex + mapBlock.end; + } + if (mapBlocks.length !== 2) { + fail( + `${flutterHelpersPath} convertToPurchase should declare exactly two platform maps, found ${mapBlocks.length}`, + ); + return; + } + + const normalizationKeys = new Set(["platform"]); + for (const [index, type] of ["PurchaseAndroid", "PurchaseIOS"].entries()) { + const segments = splitTopLevelSegments( + mapBlocks[index], + maskDartCommentsAndStrings, + ); + if (segments[0] !== "...sourcePayload") { + fail( + `${flutterHelpersPath} ${type} map must start with ...sourcePayload`, + ); + } + const trailingSpread = segments + .slice(1) + .find((segment) => segment.startsWith("...")); + if (trailingSpread) { + fail( + `${flutterHelpersPath} ${type} map must not overwrite normalized fields with ${trailingSpread}`, + ); + } + + const entries = parseDartMapEntries( + mapBlocks[index], + `${flutterHelpersPath} ${type} map`, + ); + for (const key of purchaseFields[type]) { + if (!entries.has(key) || normalizationKeys.has(key)) continue; + const sourceKey = firstCanonicalSourceReference( + entries.get(key), + convertFunction.body, + canonicalHelperIsValid, + ); + if (sourceKey !== key) { + fail( + `${flutterHelpersPath} ${type}.${key} override must read canonical sourcePayload['${key}'] first`, + ); + } + } + } + + expectIncludes( + "libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt", + [ + "array.put(JSONObject(purchase.toJson()))", + "val payload = JSONObject(p.toJson())", + "val payload = JSONObject(purchase.toJson())", + ], + "Flutter Android native purchase serialization", + ); + for (const applePluginPath of [ + "libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift", + "libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift", + ]) { + expectIncludes( + applePluginPath, + [ + "OpenIapSerialization.purchase(purchase)", + "OpenIapSerialization.purchases(purchases)", + ], + `${applePluginPath} native purchase serialization`, + ); + } +} + +function checkReactNativePurchasePayloadContracts() { + const generatedTypesPath = "packages/gql/src/generated/types.ts"; + const nitroSpecPath = "libraries/react-native-iap/src/specs/RnIap.nitro.ts"; + const typeBridgePath = "libraries/react-native-iap/src/utils/type-bridge.ts"; + const kotlinBridgePath = + "libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt"; + const swiftBridgePath = "libraries/react-native-iap/ios/RnIapHelper.swift"; + for (const relativePath of [ + generatedTypesPath, + nitroSpecPath, + typeBridgePath, + kotlinBridgePath, + swiftBridgePath, + ]) { + expectFile(relativePath); + } + if ( + !exists(generatedTypesPath) || + !exists(nitroSpecPath) || + !exists(typeBridgePath) || + !exists(kotlinBridgePath) || + !exists(swiftBridgePath) + ) { + return; + } + + const generatedTypes = read(generatedTypesPath); + const nitroSpec = read(nitroSpecPath); + const typeBridge = read(typeBridgePath); + const kotlinBridge = read(kotlinBridgePath); + const swiftBridge = read(swiftBridgePath); + const purchaseCommonFields = parseTypeScriptInterfaceFieldNames( + generatedTypes, + "PurchaseCommon", + generatedTypesPath, + ); + const canonicalFields = { + PurchaseAndroid: [ + ...new Set([ + ...purchaseCommonFields, + ...parseTypeScriptInterfaceFieldNames( + generatedTypes, + "PurchaseAndroid", + generatedTypesPath, + ), + ]), + ].sort(), + PurchaseIOS: [ + ...new Set([ + ...purchaseCommonFields, + ...parseTypeScriptInterfaceFieldNames( + generatedTypes, + "PurchaseIOS", + generatedTypesPath, + ), + ]), + ].sort(), + }; + const canonicalUnion = [ + ...new Set(Object.values(canonicalFields).flat()), + ].sort(); + const nitroFields = parseTypeScriptInterfaceFieldNames( + nitroSpec, + "NitroPurchase", + nitroSpecPath, + ); + const legacyTransportFields = [ + "purchaseStateAndroid", + "purchaseTokenAndroid", + ]; + expectSameSet( + "React Native NitroPurchase transport fields", + [...canonicalUnion, ...legacyTransportFields], + nitroFields, + ); + + const converter = extractTypeScriptFunctionBody( + typeBridge, + "export function convertNitroPurchaseToPurchase", + `${typeBridgePath} convertNitroPurchaseToPurchase`, + ); + if (converter) { + for (const [type, marker] of [ + ["PurchaseIOS", "const iosPurchase: PurchaseIOS ="], + ["PurchaseAndroid", "const androidPurchase: PurchaseAndroid ="], + ]) { + const entries = parseTypeScriptObjectEntries( + converter.body, + marker, + `${typeBridgePath} ${type} object`, + ); + expectSameSet( + `React Native convertNitroPurchaseToPurchase ${type} fields`, + canonicalFields[type], + [...entries.keys()], + ); + for (const field of canonicalFields[type]) { + const expression = entries.get(field) ?? ""; + const sourceKey = firstNitroPurchaseSourceReference( + expression, + converter.body, + ); + if (sourceKey !== field) { + fail( + `${typeBridgePath} ${type}.${field} must read nitroPurchase.${field} first`, + ); + } + const allowedSources = new Set([field]); + if (field === "purchaseState") { + allowedSources.add("purchaseStateAndroid"); + } + if (field === "purchaseToken") { + allowedSources.add("purchaseTokenAndroid"); + } + if (field === "autoRenewingAndroid") { + allowedSources.add("isAutoRenewing"); + } + if (field === "transactionId") { + allowedSources.add("id"); + allowedSources.add("purchaseToken"); + allowedSources.add("purchaseTokenAndroid"); + allowedSources.add("store"); + } + expectOnlySourceReferences( + typeScriptMemberReferences( + expression, + "nitroPurchase", + converter.body, + ), + allowedSources, + `${typeBridgePath} ${type}.${field}`, + ); + } + } + } + + const kotlinConverter = extractFunctionBody( + kotlinBridge, + "private fun convertToNitroPurchase", + `${kotlinBridgePath} convertToNitroPurchase`, + ); + if (kotlinConverter) { + const entries = parseNamedCallArguments( + kotlinConverter.body, + "return NitroPurchase", + "=", + `${kotlinBridgePath} NitroPurchase constructor`, + ); + expectSameSet( + "React Native Android NitroPurchase constructor fields", + nitroFields, + [...entries.keys()], + ); + for (const field of canonicalFields.PurchaseAndroid) { + if (field === "platform") continue; + const sourceKey = firstKotlinPurchaseSourceReference( + entries.get(field) ?? "", + ); + if (sourceKey !== field) { + fail( + `${kotlinBridgePath} NitroPurchase.${field} must read the matching native Purchase field`, + ); + } + } + } + + const swiftConverter = extractFunctionBody( + swiftBridge, + "static func convertPurchaseDictionary", + `${swiftBridgePath} convertPurchaseDictionary`, + ); + if (swiftConverter) { + const entries = parseNamedCallArguments( + swiftConverter.body, + "return NitroPurchase", + ":", + `${swiftBridgePath} NitroPurchase constructor`, + ); + expectSameSet( + "React Native iOS NitroPurchase constructor fields", + nitroFields, + [...entries.keys()], + ); + for (const field of canonicalFields.PurchaseIOS) { + if (field === "platform") continue; + const expression = entries.get(field) ?? ""; + const sourceKey = firstSwiftDictionarySourceReference( + expression, + swiftConverter.body, + ); + if (sourceKey !== field) { + fail( + `${swiftBridgePath} NitroPurchase.${field} must read dictionary["${field}"] first`, + ); + } + expectOnlySourceReferences( + swiftDictionarySourceReferences(expression), + new Set([field]), + `${swiftBridgePath} NitroPurchase.${field}`, + ); + } + } +} + +function checkReactNativeActiveSubscriptionPayloadContracts() { + const generatedTypesPath = "packages/gql/src/generated/types.ts"; + const nitroSpecPath = "libraries/react-native-iap/src/specs/RnIap.nitro.ts"; + const indexPath = "libraries/react-native-iap/src/index.ts"; + const swiftBridgePath = "libraries/react-native-iap/ios/RnIapHelper.swift"; + for (const relativePath of [ + generatedTypesPath, + nitroSpecPath, + indexPath, + swiftBridgePath, + ]) { + expectFile(relativePath); + } + if ( + !exists(generatedTypesPath) || + !exists(nitroSpecPath) || + !exists(indexPath) || + !exists(swiftBridgePath) + ) { + return; + } + + const generatedTypes = read(generatedTypesPath); + const nitroSpec = read(nitroSpecPath); + const indexSource = read(indexPath); + const swiftBridge = read(swiftBridgePath); + const activeFields = parseTypeScriptInterfaceFieldNames( + generatedTypes, + "ActiveSubscription", + generatedTypesPath, + ); + const renewalFields = parseTypeScriptInterfaceFieldNames( + generatedTypes, + "RenewalInfoIOS", + generatedTypesPath, + ); + const nitroActiveFields = parseTypeScriptInterfaceFieldNames( + nitroSpec, + "NitroActiveSubscription", + nitroSpecPath, + ); + const nitroRenewalFields = parseTypeScriptInterfaceFieldNames( + nitroSpec, + "NitroRenewalInfoIOS", + nitroSpecPath, + ); + expectSameSet( + "React Native NitroActiveSubscription transport fields", + activeFields, + nitroActiveFields, + ); + expectSameSet( + "React Native NitroRenewalInfoIOS transport fields", + renewalFields, + nitroRenewalFields, + ); + + const swiftActiveConverter = extractFunctionBody( + swiftBridge, + "static func convertActiveSubscriptionDictionary", + `${swiftBridgePath} convertActiveSubscriptionDictionary`, + ); + if (swiftActiveConverter) { + const entries = parseNamedCallArguments( + swiftActiveConverter.body, + "return NitroActiveSubscription", + ":", + `${swiftBridgePath} NitroActiveSubscription constructor`, + ); + expectSameSet( + "React Native iOS NitroActiveSubscription constructor fields", + nitroActiveFields, + [...entries.keys()], + ); + const androidOnlyFields = new Set([ + "autoRenewingAndroid", + "basePlanIdAndroid", + "purchaseTokenAndroid", + ]); + for (const field of activeFields) { + if (androidOnlyFields.has(field)) continue; + const expression = entries.get(field) ?? ""; + const sourceKey = firstSwiftDictionarySourceReference( + expression, + swiftActiveConverter.body, + ); + if (sourceKey !== field) { + fail( + `${swiftBridgePath} NitroActiveSubscription.${field} must read dictionary["${field}"] first`, + ); + } + expectOnlySourceReferences( + swiftDictionarySourceReferences(expression), + new Set([field]), + `${swiftBridgePath} NitroActiveSubscription.${field}`, + ); + } + } + + const swiftRenewalConverter = extractFunctionBody( + swiftBridge, + "static func convertRenewalInfoFromOpenIAP", + `${swiftBridgePath} convertRenewalInfoFromOpenIAP`, + ); + if (swiftRenewalConverter) { + const entries = parseNamedCallArguments( + swiftRenewalConverter.body, + "return NitroRenewalInfoIOS", + ":", + `${swiftBridgePath} NitroRenewalInfoIOS constructor`, + ); + expectSameSet( + "React Native iOS NitroRenewalInfoIOS constructor fields", + nitroRenewalFields, + [...entries.keys()], + ); + for (const field of renewalFields) { + const expression = entries.get(field) ?? ""; + const sourceKey = firstSwiftDictionarySourceReference( + expression, + swiftRenewalConverter.body, + ); + if (sourceKey !== field) { + fail( + `${swiftBridgePath} NitroRenewalInfoIOS.${field} must read dictionary["${field}"] first`, + ); + } + expectOnlySourceReferences( + swiftDictionarySourceReferences(expression), + new Set([field]), + `${swiftBridgePath} NitroRenewalInfoIOS.${field}`, + ); + } + } + + const activeMapper = extractTypeScriptFunctionBody( + indexSource, + "export const getActiveSubscriptions", + `${indexPath} getActiveSubscriptions`, + ); + if (!activeMapper) return; + const activeEntries = parseTypeScriptObjectEntries( + activeMapper.body, + "): ActiveSubscription => (", + `${indexPath} ActiveSubscription object`, + ); + expectSameSet("React Native getActiveSubscriptions fields", activeFields, [ + ...activeEntries.keys(), + ]); + for (const field of activeFields) { + const expression = activeEntries.get(field) ?? ""; + if (!expression.includes(`sub.${field}`)) { + fail(`${indexPath} ActiveSubscription.${field} must read sub.${field}`); + } + if (field !== "renewalInfoIOS") { + const allowedSources = new Set([field]); + if (field === "currentPlanId") allowedSources.add("productId"); + expectOnlySourceReferences( + typeScriptMemberReferences(expression, "sub"), + allowedSources, + `${indexPath} ActiveSubscription.${field}`, + ); + } + } + + const renewalEntries = parseTypeScriptObjectEntries( + activeMapper.body, + "renewalInfoIOS: sub.renewalInfoIOS", + `${indexPath} RenewalInfoIOS object`, + ); + expectSameSet( + "React Native getActiveSubscriptions RenewalInfoIOS fields", + renewalFields, + [...renewalEntries.keys()], + ); + for (const field of renewalFields) { + const expression = renewalEntries.get(field) ?? ""; + if (!expression.includes(`sub.renewalInfoIOS.${field}`)) { + fail( + `${indexPath} RenewalInfoIOS.${field} must read sub.renewalInfoIOS.${field}`, + ); + } + expectOnlySourceReferences( + nestedTypeScriptMemberReferences(expression, "sub", "renewalInfoIOS"), + new Set([field]), + `${indexPath} RenewalInfoIOS.${field}`, + ); + } +} + +function checkKmpPurchasePayloadContracts() { + const generatedTypesPath = + "libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt"; + const helperPath = + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt"; + for (const relativePath of [generatedTypesPath, helperPath]) { + expectFile(relativePath); + } + if (!exists(generatedTypesPath) || !exists(helperPath)) return; + + const generatedTypes = read(generatedTypesPath); + const helper = read(helperPath); + const purchaseFields = parseKotlinToJsonKeys( + generatedTypes, + "PurchaseAndroid", + generatedTypesPath, + ); + const mapper = extractFunctionBody( + helper, + "internal fun com.android.billingclient.api.Purchase.toPurchase", + `${helperPath} Purchase.toPurchase`, + ); + if (!mapper) return; + + const entries = parseNamedCallArguments( + mapper.body, + "return PurchaseAndroid", + "=", + `${helperPath} PurchaseAndroid constructor`, + ); + expectMappedGeneratedFields( + "KMP Android Purchase mapper fields", + purchaseFields, + entries, + ["currentPlanId"], + ); + const label = "KMP Android Purchase mapper"; + expectNamedExpression(entries, "dataAndroid", /^originalJson$/, label); + expectNamedExpression( + entries, + "developerPayloadAndroid", + /^developerPayload$/, + label, + ); + expectNamedExpression(entries, "purchaseToken", /^purchaseToken$/, label); + expectNamedExpression(entries, "ids", /^products$/, label); + expectNamedExpression( + entries, + "productId", + /^products\.firstOrNull\(\) \?: ""$/, + label, + ); + expectNamedExpression(entries, "signatureAndroid", /^signature$/, label); + expectNamedExpression(entries, "transactionId", /^orderId$/, label); +} + +function checkGooglePurchasePayloadContracts() { + const generatedTypesPath = + "packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt"; + const mappers = [ + { + path: "packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt", + marker: "fun BillingPurchase.toPurchase", + label: "Google Play Purchase mapper", + sourceExpressions: { + dataAndroid: /^originalJson$/, + developerPayloadAndroid: /^developerPayload$/, + ids: /^products$/, + productId: /^products\.firstOrNull\(\)\.orEmpty\(\)$/, + purchaseToken: /^purchaseToken$/, + signatureAndroid: /^signature$/, + transactionId: /^orderId$/, + }, + }, + { + path: "packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt", + marker: "fun HorizonPurchase.toPurchase", + label: "Google Horizon Purchase mapper", + sourceExpressions: { + dataAndroid: /^originalJson$/, + developerPayloadAndroid: /^developerPayload$/, + ids: /^productsList$/, + productId: /^productsList\.firstOrNull\(\)\.orEmpty\(\)$/, + purchaseToken: /^token$/, + signatureAndroid: /^signature$/, + transactionId: /^orderId \?: token$/, + }, + intentionallyDefaultedFields: [ + "isSuspendedAndroid", + "pendingPurchaseUpdateAndroid", + ], + }, + { + path: "packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt", + marker: "internal fun buildAmazonPurchase", + label: "Google Amazon Purchase mapper", + sourceExpressions: { + currentPlanId: + /^if \(isSubscription\) resolvedCurrentPlanId else null$/, + dataAndroid: /^""$/, + ids: /^listOf\(resolvedProductId\)$/, + productId: /^resolvedProductId$/, + purchaseToken: /^receiptId$/, + signatureAndroid: /^null$/, + transactionId: /^receiptId$/, + }, + intentionallyDefaultedFields: [ + "developerPayloadAndroid", + "obfuscatedAccountIdAndroid", + "obfuscatedProfileIdAndroid", + ], + }, + ]; + expectFile(generatedTypesPath); + for (const mapper of mappers) expectFile(mapper.path); + if ( + !exists(generatedTypesPath) || + mappers.some((mapper) => !exists(mapper.path)) + ) { + return; + } + + const purchaseFields = parseKotlinToJsonKeys( + read(generatedTypesPath), + "PurchaseAndroid", + generatedTypesPath, + ); + for (const mapper of mappers) { + const body = extractFunctionBody( + read(mapper.path), + mapper.marker, + `${mapper.path} ${mapper.marker}`, + ); + if (!body) continue; + const entries = parseNamedCallArguments( + body.body, + "return PurchaseAndroid", + "=", + `${mapper.path} PurchaseAndroid constructor`, + ); + expectMappedGeneratedFields( + mapper.label, + purchaseFields, + entries, + mapper.intentionallyDefaultedFields, + ); + for (const [field, pattern] of Object.entries(mapper.sourceExpressions)) { + expectNamedExpression(entries, field, pattern, mapper.label); + } + } + expectIncludes( + "packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt", + [ + "val resolvedCurrentPlanId = termSku?.takeIf { it.isNotBlank() } ?: resolvedProductId", + "termSku = termSku", + "): Boolean = !isCanceled && !hasCancelDate", + "shouldIncludeAmazonReceipt(", + "isCanceled = it.isCanceled", + "hasCancelDate = it.cancelDate != null", + ], + "Google Amazon current-plan source mapping", + ); + expectIncludes( + "packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonSubscriptionGroupMappingTest.kt", + [ + "available purchases reject both Amazon cancellation signals", + "isCanceled = true,\n hasCancelDate = false", + "isCanceled = false,\n hasCancelDate = true", + ], + "Google Amazon cancellation filtering tests", + ); + const amazonPath = + "packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt"; + const requestUpdates = extractFunctionBody( + read(amazonPath), + "private suspend fun requestPurchaseUpdates", + `${amazonPath} requestPurchaseUpdates`, + ); + if (requestUpdates) { + const cancellationFilter = extractBalancedAfterMarker( + requestUpdates.body, + ".filter", + "{", + "}", + `${amazonPath} available purchase cancellation filter`, + maskKotlinCommentsAndStrings, + ); + if ( + cancellationFilter && + !/^shouldIncludeAmazonReceipt\(\s*isCanceled = it\.isCanceled,\s*hasCancelDate = it\.cancelDate != null,\s*\)$/.test( + normalizeExpression(cancellationFilter.body), + ) + ) { + fail( + `${amazonPath} available purchase filter must exclusively use both Amazon cancellation signals`, + ); + } + } +} + +function checkVegaPurchasePayloadContracts() { + const generatedTypesPath = "packages/gql/src/generated/types.ts"; + expectFile(generatedTypesPath); + if (!exists(generatedTypesPath)) return; + const generatedTypes = read(generatedTypesPath); + const purchaseAndroidFields = [ + ...new Set([ + ...parseTypeScriptInterfaceFieldNames( + generatedTypes, + "PurchaseCommon", + generatedTypesPath, + ), + ...parseTypeScriptInterfaceFieldNames( + generatedTypes, + "PurchaseAndroid", + generatedTypesPath, + ), + ]), + ].sort(); + + for (const { relativePath, transportFields } of [ + { + relativePath: "libraries/react-native-iap/src/vega-adapter.ts", + transportFields: ["purchaseStateAndroid", "purchaseTokenAndroid"], + }, + { + relativePath: "libraries/expo-iap/src/vega-adapter.ts", + transportFields: [], + }, + ]) { + expectFile(relativePath); + if (!exists(relativePath)) continue; + const source = read(relativePath); + const receiptInterface = extractBalancedAfterMarker( + source, + "interface VegaReceipt ", + "{", + "}", + `${relativePath} VegaReceipt`, + maskTypeScriptCommentsAndStrings, + ); + const receiptFields = receiptInterface + ? uniqueMatches( + receiptInterface.body, + /^\s*(?:readonly\s+)?([A-Za-z][A-Za-z0-9_]*)\??\s*:/gm, + ) + : []; + for (const field of ["deferredSku", "isDeferred", "termSku"]) { + if (!receiptFields.includes(field)) { + fail(`${relativePath} VegaReceipt must expose ${field}`); + } + } + const mapper = extractTypeScriptFunctionBody( + source, + "function mapReceipt", + `${relativePath} mapReceipt`, + ); + if (!mapper) continue; + const entries = parseTypeScriptObjectEntries( + mapper.body, + "return", + `${relativePath} mapReceipt result`, + ); + expectSameSet( + `${relativePath} mapReceipt fields`, + [...purchaseAndroidFields, ...transportFields], + [...entries.keys()], + ); + for (const field of ["id", "purchaseToken", "transactionId"]) { + if (entries.get(field)?.trim() !== "receiptId") { + fail(`${relativePath} mapReceipt.${field} must preserve receiptId`); + } + } + if (!entries.get("ids")?.includes("productId")) { + fail(`${relativePath} mapReceipt.ids must preserve productId`); + } + expectNamedExpression( + entries, + "currentPlanId", + /^type === ['"]subs['"] \? \(receipt\.termSku \?\? productId\) : null$/, + `${relativePath} mapReceipt`, + ); + expectNamedExpression( + entries, + "isSuspendedAndroid", + /^false$/, + `${relativePath} mapReceipt`, + ); + const pendingUpdate = normalizeExpression( + entries.get("pendingPurchaseUpdateAndroid") ?? "", + ); + if ( + !pendingUpdate.includes("receipt.isDeferred") || + !pendingUpdate.includes("receipt.deferredSku") || + pendingUpdate.includes("receipt.termSku") + ) { + fail( + `${relativePath} mapReceipt.pendingPurchaseUpdateAndroid must use deferredSku for deferred changes`, + ); + } + const activeDeclaration = mapper.body.match( + /\bconst\s+isActive\s*=\s*([^;]+);/, + )?.[1]; + if (normalizeExpression(activeDeclaration ?? "") !== "!isCanceled") { + fail( + `${relativePath} mapReceipt must keep deferred subscriptions active until cancellation`, + ); + } + for (const field of [ + "isAutoRenewing", + "autoRenewingAndroid", + "purchaseState", + ]) { + if (!entries.get(field)?.includes("isActive")) { + fail(`${relativePath} mapReceipt.${field} must derive from isActive`); + } + } + const maskedSource = maskTypeScriptCommentsAndStrings(source); + if ( + /if\s*\([^)]*\bisDeferred\b[^)]*\)\s*(?:return\s+false|continue)/.test( + maskedSource, + ) || + /!\s*receipt\.isDeferred/.test(maskedSource) + ) { + fail( + `${relativePath} must not drop an active receipt merely because its plan change is deferred`, + ); + } + const availableMarker = relativePath.includes("react-native-iap") + ? "const getAvailablePurchases = async" + : "const getAvailableItems = async"; + const availableMapper = extractTypeScriptFunctionBody( + source, + availableMarker, + `${relativePath} available purchase mapper`, + ); + if (availableMapper) { + const filterBody = extractBalancedAfterMarker( + availableMapper.body, + ".filter((receipt) =>", + "{", + "}", + `${relativePath} available purchase filter`, + maskTypeScriptCommentsAndStrings, + ); + if ( + filterBody && + /\bisDeferred\b|\bdeferredSku\b/.test( + maskTypeScriptCommentsAndStrings(filterBody.body), + ) + ) { + fail( + `${relativePath} available purchase filter must not treat deferred plan changes as inactive`, + ); + } + } + } +} + +function checkPurchaseRoundTripRegressionCoverage() { + expectIncludes( + "libraries/godot-iap/Example/tests/test_types_only.gd", + [ + 'parsed.current_plan_id, "base-plan-monthly"', + 'parsed.data_android, "{\\"orderId\\":\\"txn-1\\"}"', + "parsed.is_suspended_android, true", + "parsed.pending_purchase_update_android.products[0]", + "parsed.pending_purchase_update_android.purchase_token", + 'parsed.current_plan_id, "premium.monthly"', + 'parsed.offer_ios.id, "launch-offer"', + "parsed.advanced_commerce_info_ios.request_reference_id", + "parsed.advanced_commerce_info_ios.items[0].details.json_representation", + ], + "Godot canonical purchase round-trip regression coverage", + ); +} + +export function collectPurchasePayloadParityFailures(repoRoot) { + root = repoRoot; + failures = []; + checkFlutterPayloadContracts(); + checkReactNativePurchasePayloadContracts(); + checkReactNativeActiveSubscriptionPayloadContracts(); + checkKmpPurchasePayloadContracts(); + checkGooglePurchasePayloadContracts(); + checkVegaPurchasePayloadContracts(); + checkPurchaseRoundTripRegressionCoverage(); + return [...failures]; +} + +export function inspectNamedArguments( + source, + marker, + separator, + language = "kotlin", +) { + const previousFailures = failures; + failures = []; + const mask = + language === "dart" + ? maskDartCommentsAndStrings + : language === "typescript" + ? maskTypeScriptCommentsAndStrings + : maskKotlinCommentsAndStrings; + const entries = parseNamedCallArguments( + source, + marker, + separator, + "payload parser fixture", + mask, + ); + const issues = [...failures]; + failures = previousFailures; + return { entries, issues }; +} + +export function inspectMappedGeneratedFields( + generatedFields, + mappedFields, + intentionallyDefaultedFields = [], +) { + const previousFailures = failures; + failures = []; + expectMappedGeneratedFields( + "generated field fixture", + generatedFields, + new Map(mappedFields.map((field) => [field, field])), + intentionallyDefaultedFields, + ); + const issues = [...failures]; + failures = previousFailures; + return issues; +} + +export function inspectFlutterCanonicalExpression( + helperSource, + expression, + functionBody = "", +) { + const previousFailures = failures; + failures = []; + const helperIsValid = validateCanonicalOrLegacyHelper( + helperSource, + "Flutter helper fixture", + ); + const sourceKey = firstCanonicalSourceReference( + expression, + functionBody, + helperIsValid, + ); + const issues = [...failures]; + failures = previousFailures; + return { issues, sourceKey }; +} + +export { + extractBalancedAfterMarker, + maskKotlinCommentsAndStrings, + maskTypeScriptCommentsAndStrings, +}; diff --git a/scripts/audit-purchase-payload-parity.test.mjs b/scripts/audit-purchase-payload-parity.test.mjs new file mode 100644 index 000000000..3f5370efa --- /dev/null +++ b/scripts/audit-purchase-payload-parity.test.mjs @@ -0,0 +1,198 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + inspectFlutterCanonicalExpression, + inspectMappedGeneratedFields, + inspectNamedArguments, +} from "./audit-purchase-payload-parity.mjs"; + +const canonicalHelper = ` +dynamic _canonicalOrLegacy( + Map payload, { + required String canonicalKey, + required String legacyKey, +}) { + final canonical = payload[canonicalKey]; + if (canonical != null) { + return canonical; + } + final legacy = payload[legacyKey]; + return legacy; +} +`; + +test("Flutter canonical helper preserves canonical-first payload lookup", () => { + const result = inspectFlutterCanonicalExpression( + canonicalHelper, + `_canonicalOrLegacy( + sourcePayload, + canonicalKey: 'dataAndroid', + legacyKey: 'originalJsonAndroid', + )?.toString()`, + ); + + assert.deepEqual(result, { issues: [], sourceKey: "dataAndroid" }); +}); + +test("Flutter canonical helper rejects a legacy-first implementation", () => { + const legacyFirst = canonicalHelper.replace( + `final canonical = payload[canonicalKey]; + if (canonical != null) { + return canonical; + } + final legacy = payload[legacyKey];`, + `final legacy = payload[legacyKey]; + if (legacy != null) { + return legacy; + } + final canonical = payload[canonicalKey];`, + ); + const result = inspectFlutterCanonicalExpression( + legacyFirst, + `_canonicalOrLegacy( + sourcePayload, + canonicalKey: 'purchaseState', + legacyKey: 'purchaseStateAndroid', + )`, + ); + + assert.equal(result.sourceKey, null); + assert.match(result.issues.join("\n"), /canonicalKey.*legacyKey/); +}); + +test("Flutter canonical helper call sites retain their declared source key", () => { + const result = inspectFlutterCanonicalExpression( + canonicalHelper, + `_coerceAndroidPurchaseState( + _canonicalOrLegacy( + sourcePayload, + canonicalKey: 'purchaseState', + legacyKey: 'purchaseStateAndroid', + ), + )`, + ); + + assert.deepEqual(result, { issues: [], sourceKey: "purchaseState" }); +}); + +test("Flutter canonical helper call sites cannot claim a different field", () => { + const result = inspectFlutterCanonicalExpression( + canonicalHelper, + `_canonicalOrLegacy( + sourcePayload, + canonicalKey: 'originalJsonAndroid', + legacyKey: 'dataAndroid', + )`, + ); + + assert.deepEqual(result, { + issues: [], + sourceKey: "originalJsonAndroid", + }); +}); + +test("Flutter canonical helper ignores canonical-looking comments", () => { + const decoy = ` +dynamic _canonicalOrLegacy( + Map payload, { + required String canonicalKey, + required String legacyKey, +}) { + // final canonical = payload[canonicalKey]; + // if (canonical != null) { return canonical; } + final legacy = payload[legacyKey]; + return legacy; +} +`; + const result = inspectFlutterCanonicalExpression( + decoy, + `_canonicalOrLegacy( + sourcePayload, + canonicalKey: 'purchaseState', + legacyKey: 'transactionStateIOS', + )`, + ); + + assert.equal(result.sourceKey, null); + assert.match(result.issues.join("\n"), /canonicalKey.*legacyKey/); +}); + +test("Flutter canonical helper rejects an early return before canonical data", () => { + const earlyReturn = canonicalHelper.replace( + "final canonical = payload[canonicalKey];", + "if (payload.isEmpty) return legacy;\n final canonical = payload[canonicalKey];", + ); + const result = inspectFlutterCanonicalExpression( + earlyReturn, + `_canonicalOrLegacy( + sourcePayload, + canonicalKey: 'purchaseState', + legacyKey: 'purchaseStateAndroid', + )`, + ); + + assert.equal(result.sourceKey, null); + assert.match(result.issues.join("\n"), /canonicalKey.*legacyKey/); +}); + +test("a fallback before the helper remains the first payload source", () => { + const result = inspectFlutterCanonicalExpression( + canonicalHelper, + `sourcePayload['originalJsonAndroid'] ?? + _canonicalOrLegacy( + sourcePayload, + canonicalKey: 'dataAndroid', + legacyKey: 'originalJsonAndroid', + )`, + ); + + assert.deepEqual(result, { + issues: [], + sourceKey: "originalJsonAndroid", + }); +}); + +test("Kotlin payload parsing ignores decoys and nested commas", () => { + const source = ` + // return PurchaseAndroid(fake = "comment, decoy") + return PurchaseAndroid( + id = purchase.id, + dataAndroid = serialize("value,with,commas"), + pendingPurchaseUpdateAndroid = PendingPurchaseUpdateAndroid( + products = listOf("one", "two"), + purchaseToken = purchase.purchaseToken, + ), + ) + `; + const result = inspectNamedArguments(source, "return PurchaseAndroid", "="); + + assert.deepEqual(result.issues, []); + assert.deepEqual([...result.entries.keys()].sort(), [ + "dataAndroid", + "id", + "pendingPurchaseUpdateAndroid", + ]); + assert.equal( + result.entries.get("dataAndroid")?.replace(/\s+/g, " ").trim(), + 'serialize("value,with,commas")', + ); +}); + +test("generated mapping defaults remain tied to generated fields", () => { + assert.deepEqual( + inspectMappedGeneratedFields( + ["dataAndroid", "id", "optionalField"], + ["dataAndroid", "id"], + ["optionalField"], + ), + [], + ); + assert.match( + inspectMappedGeneratedFields( + ["dataAndroid", "id"], + ["dataAndroid", "id"], + ["inventedField"], + ).join("\n"), + /unknown defaulted fields: inventedField/, + ); +}); From 4c656abf8bd76151dcf076764980be041c4a1a4b Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 24 Jul 2026 22:19:57 +0900 Subject: [PATCH 06/16] docs: document payload preservation Record the planned SDK patch releases and add generated-payload preservation guidance to the platform and compiled AI documentation. --- knowledge/internal/04-platform-packages.md | 21 ++++ .../docs/src/pages/docs/updates/releases.tsx | 96 ++++++++++--------- 2 files changed, 74 insertions(+), 43 deletions(-) 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/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index f52adcf15..d423242d5 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,7 +193,7 @@ 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', date: new Date('2026-07-24'), @@ -201,7 +206,7 @@ function Releases() { id="flutter-purchase-payload-fix-planned-2026-07-24" level="h4" > - July 24, 2026 - Flutter purchase payload patch (planned) + July 24, 2026 - Cross-SDK native payload integrity patches (planned)

- Prepares a focused Flutter patch for{' '} + Prepares a coordinated payload-integrity patch train based on{' '} issue #248 {' '} - in{' '} + and{' '} PR #251 - . This entry remains planned until the Flutter release workflow - publishes the package and GitHub tag. The OpenIAP specification and - native package versions are unchanged. + . This entry remains planned until the affected release workflows + publish their packages and GitHub tags. OpenIAP Spec stays at{' '} + 2.4.2, the minimum of openiap-apple 2.4.2{' '} + and openiap-google 2.5.0; this source change does not + bump package metadata.

-
Flutter purchase payloads
+
+ Package-specific payload fixes +
  • - Reads Google Play's signed purchase JSON from the canonical{' '} - PurchaseAndroid.dataAndroid key. Flutter 9.6.0 and - earlier 9.x releases read only a nonexistent compatibility key, - leaving dataAndroid null for listener and available- - purchase results. + openiap-google - rejects Amazon receipts when + either cancellation signal is present, keeps the subscription term + SKU as currentPlanId, and reports deferred plan + changes through pendingPurchaseUpdateAndroid. +
  • +
  • + react-native-iap - carries explicit native + transaction identity through Nitro, leaves orderless Google Play + transactions without a synthetic order ID, preserves Apple renewal + metadata, and models deferred Vega plan changes without hiding the + active receipt.
  • - Preserves the complete normalized generated Android and iOS - purchase payload before applying compatibility conversions, so - newly added optional fields are not silently dropped by a local - allowlist. + expo-iap - fails closed when an Onside module or + method is unavailable instead of falling through to StoreKit, + classifies Onside subscriptions with their canonical metadata, and + preserves Vega current-plan and pending-update semantics.
  • - Keeps originalJsonAndroid as a fallback input alias - only for the remainder of Flutter 9.x. It is not a public Purchase - field, canonical dataAndroid wins when both are - present, and the fallback is scheduled for removal in{' '} - flutter_inapp_purchase 10.0.0. + flutter_inapp_purchase - reads canonical{' '} + dataAndroid, preserves complete generated purchase + and verification results, supports Horizon verification, and + recursively normalizes nested native maps. Flutter 9.x keeps its + documented wire fallbacks until 10.0.0.
  • - Preserves the remaining Flutter 9.x wire fallbacks while warning - custom integrations to migrate before 10.0.0:{' '} - purchaseStateAndroid and{' '} - transactionStateIOS move to{' '} - purchaseState, transactionReceipt moves - to purchaseToken, transactionId must be - emitted explicitly, and iOS/macOS verification uses{' '} - {'{ apple: { sku } }'} instead of a top-level{' '} - {'{ sku }'}. + kmp-iap - preserves complete generated Android + and iOS purchase fields, including developer payload, pending + updates, purchase tokens, transaction identity, and normalized + Apple bridge null values.
  • - Moves the official Dart bridge to canonical in-app,{' '} - apple / google, - getAppTransactionIOS /{' '} - subscriptionStatusIOS, and skuAndroid /{' '} - packageNameAndroid payloads before enabling one-time - compatibility warnings. Normal SDK calls stay silent; only a - selected legacy custom-channel fallback warns. + OpenIap.Maui - reports listener deserialization + drift with credential-safe diagnostics instead of silently + dropping malformed purchase events.
  • Custom MethodChannel callers should also replace{' '} @@ -298,12 +304,16 @@ function Releases() { color: 'var(--text-secondary)', }} > - Custom MethodChannel adapters, mocks, and fixtures should migrate to{' '} - dataAndroid now. See{' '} + The SDK parity audit now compares generated purchase and renewal + fields with handwritten bridges, enforces source-first mappings and + alternative-store deferred-plan semantics, and keeps round-trip + regressions executable across wrappers. Godot receives the shared + regression coverage only, so this diff does not require a Godot + package release. See{' '} Deprecations & 3.0 Migration {' '} - for the complete removal schedule. + for Flutter's compatibility-input removal schedule.

    - {plannedFlutterPurchasePayloadReleases.map((release) => ( + {plannedCrossSdkPurchasePayloadReleases.map((release) => (
  • {release}
  • ))}
From f8c13c925fd441aef8124176df14704019f9e4b6 Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 24 Jul 2026 22:22:23 +0900 Subject: [PATCH 07/16] docs: link payload follow-up Link the stacked cross-SDK implementation PR from the planned payload-integrity release note. --- packages/docs/src/pages/docs/updates/releases.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index d423242d5..8aae45ff2 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -233,6 +233,15 @@ function Releases() { > PR #251 + {' and '} + + PR #252 + . This entry remains planned until the affected release workflows publish their packages and GitHub tags. OpenIAP Spec stays at{' '} 2.4.2, the minimum of openiap-apple 2.4.2{' '} From 06b7fc650329b023d5cf599d7786e8be0a3b70d5 Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 24 Jul 2026 22:57:17 +0900 Subject: [PATCH 08/16] fix: address payload review feedback --- .../expo-iap/ios/onside/OnsideIapModule.swift | 30 ++++++--- .../__tests__/native-log-redaction.test.js | 17 ++++++ .../BillingPurchasePayloadMappingTest.kt | 2 + .../kmpiap/ProductPayloadNormalizerIOS.kt | 61 +++++++++++++++++-- .../kmpiap/ProductPayloadNormalizerTestIOS.kt | 59 ++++++++++++++++++ .../src/__tests__/vega-adapter.test.ts | 19 +++++- .../react-native-iap/src/vega-adapter.ts | 4 +- .../docs/src/pages/docs/updates/releases.tsx | 6 +- 8 files changed, 177 insertions(+), 21 deletions(-) diff --git a/libraries/expo-iap/ios/onside/OnsideIapModule.swift b/libraries/expo-iap/ios/onside/OnsideIapModule.swift index e7837cefa..95fb55967 100644 --- a/libraries/expo-iap/ios/onside/OnsideIapModule.swift +++ b/libraries/expo-iap/ios/onside/OnsideIapModule.swift @@ -469,7 +469,7 @@ public final class ExpoIapOnsideModule: Module { dictionary["currency"] = product.price.currencyCode dictionary["price"] = priceNumber let subscriptionPeriod = product.subscriptionPeriod.map { - subscriptionPeriodComponents($0) + subscriptionPeriodComponentsIOS($0) } let isSubscription = subscriptionPeriod != nil dictionary["type"] = isSubscription ? "subs" : "in-app" @@ -481,15 +481,15 @@ public final class ExpoIapOnsideModule: Module { dictionary["subscriptionPeriodUnitIOS"] = subscriptionPeriod.unit } if let introductoryPrice = product.introductoryPrice { - let introductoryPeriod = subscriptionPeriodComponents(introductoryPrice.period) + let introductoryPeriod = subscriptionPeriodComponentsIOS(introductoryPrice.period) dictionary["introductoryPriceAsAmountIOS"] = String(introductoryPrice.price.value) - dictionary["introductoryPriceIOS"] = formatPrice(introductoryPrice.price) + dictionary["introductoryPriceIOS"] = formatPriceIOS(introductoryPrice.price) dictionary["introductoryPriceNumberOfPeriodsIOS"] = String(introductoryPeriod.value) dictionary["introductoryPricePaymentModeIOS"] = - introductoryPrice.price.value == 0 ? "free-trial" : "empty" + introductoryPricePaymentModeIOS(for: introductoryPrice).rawValue dictionary["introductoryPriceSubscriptionPeriodIOS"] = introductoryPeriod.unit } else if isSubscription { - dictionary["introductoryPricePaymentModeIOS"] = "empty" + dictionary["introductoryPricePaymentModeIOS"] = PaymentModeIOS.empty.rawValue } // Avoid JSONEncoder on non-Encodable SDK type: build JSON string from known fields dictionary["jsonRepresentationIOS"] = try makeProductJSONRepresentation(from: product) @@ -548,7 +548,7 @@ public final class ExpoIapOnsideModule: Module { let priceNumber = makePriceNumber(from: product) let formattedPrice = priceFormatter.string(from: priceNumber) ?? "\(product.price.value)" let subscriptionPeriod = product.subscriptionPeriod.map { - subscriptionPeriodComponents($0) + subscriptionPeriodComponentsIOS($0) } var jsonObject: [String: Any] = [ "id": product.productIdentifier, @@ -583,7 +583,7 @@ public final class ExpoIapOnsideModule: Module { NSDecimalNumber(string: String(product.price.value)) } - private func formatPrice(_ price: OnsidePrice) -> String { + private func formatPriceIOS(_ price: OnsidePrice) -> String { let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.currencyCode = price.currencyCode @@ -591,7 +591,21 @@ public final class ExpoIapOnsideModule: Module { return formatter.string(from: number) ?? "\(price.value)" } - private func subscriptionPeriodComponents(_ period: OnsidePeriod) -> (value: Int, unit: String) { + 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") 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 b5e612146..cd73bed73 100644 --- a/libraries/expo-iap/src/__tests__/native-log-redaction.test.js +++ b/libraries/expo-iap/src/__tests__/native-log-redaction.test.js @@ -107,6 +107,23 @@ describe('native log redaction', () => { 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/, ); 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 index 87a84cbfd..7a3dc9834 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -40,6 +41,7 @@ class BillingPurchasePayloadMappingTest { 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) 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 6212d6228..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,6 +1,10 @@ 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? { @@ -42,22 +46,67 @@ internal fun normalizePurchasePayloadIOS(data: Any?): Map? { // 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"] = 1 + 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? { - return runCatching { - val normalized = normalizePurchasePayloadIOS(data) ?: return@runCatching null - if (normalized["platform"] != "ios") return@runCatching null - PurchaseIOS.fromJson(normalized) - }.getOrNull() + 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) { 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 60d1fa499..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 @@ -153,4 +153,63 @@ class ProductPayloadNormalizerTestIOS { 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/react-native-iap/src/__tests__/vega-adapter.test.ts b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts index e9b74c677..dfa18e92e 100644 --- a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts @@ -1144,7 +1144,13 @@ describe('Amazon Vega adapter', () => { }, ], }); - const module = createVegaIapModule(service); + const module = createVegaIapModule(service) as ReturnType< + typeof createVegaIapModule + > & { + restorePurchases(): Promise; + }; + const listener = jest.fn(); + module.addPurchaseUpdatedListener(listener); await expect( module.getAvailablePurchases({android: {type: 'subs'}}), @@ -1162,6 +1168,17 @@ describe('Amazon Vega adapter', () => { 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('verifies Vega receipts through IAPKit Amazon payload', async () => { diff --git a/libraries/react-native-iap/src/vega-adapter.ts b/libraries/react-native-iap/src/vega-adapter.ts index 9eff980eb..8b424c9f7 100644 --- a/libraries/react-native-iap/src/vega-adapter.ts +++ b/libraries/react-native-iap/src/vega-adapter.ts @@ -1557,9 +1557,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 8aae45ff2..9b4c6523d 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -195,15 +195,15 @@ function Releases() { // 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: (
July 24, 2026 - Cross-SDK native payload integrity patches (planned) From 8b6b92be1af34dc0f976113e1ac3157b5a0db4b7 Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 25 Jul 2026 00:54:53 +0900 Subject: [PATCH 09/16] docs(ai): compile payload preservation guidance --- knowledge/_claude-context/context.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) 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: From fc72f3a062266c11dc8d5a65df1a679648d7dbd3 Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 25 Jul 2026 00:55:11 +0900 Subject: [PATCH 10/16] test: align payload audit with canonical presence --- scripts/audit-purchase-payload-parity.mjs | 46 ++++------------- .../audit-purchase-payload-parity.test.mjs | 50 +++++++++++++++---- 2 files changed, 50 insertions(+), 46 deletions(-) diff --git a/scripts/audit-purchase-payload-parity.mjs b/scripts/audit-purchase-payload-parity.mjs index 032bd958c..699f9f470 100644 --- a/scripts/audit-purchase-payload-parity.mjs +++ b/scripts/audit-purchase-payload-parity.mjs @@ -481,18 +481,6 @@ function parseNamedCallArguments( return entries; } -function parseNamedCallArgumentNames( - text, - marker, - separator, - label, - mask = maskKotlinCommentsAndStrings, -) { - return [ - ...parseNamedCallArguments(text, marker, separator, label, mask).keys(), - ].sort(); -} - function normalizeExpression(expression) { return expression.replace(/\s+/g, " ").trim(); } @@ -565,10 +553,6 @@ function parseTypeScriptObjectEntries(text, marker, label) { return entries; } -function parseTypeScriptObjectFieldNames(text, marker, label) { - return [...parseTypeScriptObjectEntries(text, marker, label).keys()].sort(); -} - function parseDartMapEntries(mapBody, label) { const entries = new Map(); for (const segment of splitTopLevelSegments( @@ -606,23 +590,17 @@ function validateCanonicalOrLegacyHelper(source, label) { if (!helper) return false; const masked = maskDartCommentsAndStrings(helper.body); - const canonicalRead = - /\bfinal\s+canonical\s*=\s*payload\s*\[\s*canonicalKey\s*\]\s*;/.exec( - masked, - ); const canonicalGuard = - /\bif\s*\(\s*canonical\s*!=\s*null\s*\)\s*\{\s*return\s+canonical\s*;\s*\}/.exec( + /\bif\s*\(\s*payload\s*\.\s*containsKey\s*\(\s*canonicalKey\s*\)\s*\)\s*\{\s*return\s+payload\s*\[\s*canonicalKey\s*\]\s*;\s*\}/.exec( masked, ); const legacyRead = /\bfinal\s+legacy\s*=\s*payload\s*\[\s*legacyKey\s*\]\s*;/.exec(masked); const legacyReturn = /\breturn\s+legacy\s*;/.exec(masked); const ordered = - canonicalRead && canonicalGuard && legacyRead && legacyReturn && - canonicalRead.index < canonicalGuard.index && canonicalGuard.index < legacyRead.index && legacyRead.index < legacyReturn.index; @@ -633,17 +611,11 @@ function validateCanonicalOrLegacyHelper(source, label) { payloadReads.length === 2 && payloadReads[0] === "canonicalKey" && payloadReads[1] === "legacyKey"; - const returnValues = [ - ...masked.matchAll(/\breturn\s+([A-Za-z][A-Za-z0-9_]*)\s*;/g), - ].map((match) => match[1]); - const exactReturns = - returnValues.length === 2 && - returnValues[0] === "canonical" && - returnValues[1] === "legacy"; + const exactReturns = [...masked.matchAll(/\breturn\b/g)].length === 2; if (!ordered || !exactReads || !exactReturns) { fail( - `${label} _canonicalOrLegacy must return payload[canonicalKey] before consulting payload[legacyKey]`, + `${label} _canonicalOrLegacy must use payload.containsKey(canonicalKey) before consulting payload[legacyKey]`, ); return false; } @@ -688,17 +660,21 @@ function firstCanonicalSourceReference( const helperKey = canonicalKeyFromHelperCall(expression); if (helperKey) return helperKey; } + if (/_transactionIdFrom\s*\(\s*sourcePayload\s*\)/.test(expression)) { + return "transactionId"; + } const seenInExpression = new Set(); const references = - /sourcePayload\s*\[\s*['"]([^'"]+)['"]\s*\]|\b([A-Za-z][A-Za-z0-9_]*)\b/g; + /sourcePayload\s*(?:\[\s*['"]([^'"]+)['"]\s*\]|\.containsKey\s*\(\s*['"]([^'"]+)['"]\s*\))|\b([A-Za-z][A-Za-z0-9_]*)\b/g; for (const reference of expression.matchAll(references)) { - if (reference[1]) { + const sourceKey = reference[1] ?? reference[2]; + if (sourceKey) { return hasDominatingOperator(expression.slice(0, reference.index)) ? null - : reference[1]; + : sourceKey; } - const identifier = reference[2]; + const identifier = reference[3]; if (seenInExpression.has(identifier)) continue; seenInExpression.add(identifier); if (seenIdentifiers.has(identifier)) continue; diff --git a/scripts/audit-purchase-payload-parity.test.mjs b/scripts/audit-purchase-payload-parity.test.mjs index 3f5370efa..9c95ad30e 100644 --- a/scripts/audit-purchase-payload-parity.test.mjs +++ b/scripts/audit-purchase-payload-parity.test.mjs @@ -12,9 +12,8 @@ dynamic _canonicalOrLegacy( required String canonicalKey, required String legacyKey, }) { - final canonical = payload[canonicalKey]; - if (canonical != null) { - return canonical; + if (payload.containsKey(canonicalKey)) { + return payload[canonicalKey]; } final legacy = payload[legacyKey]; return legacy; @@ -36,16 +35,15 @@ test("Flutter canonical helper preserves canonical-first payload lookup", () => test("Flutter canonical helper rejects a legacy-first implementation", () => { const legacyFirst = canonicalHelper.replace( - `final canonical = payload[canonicalKey]; - if (canonical != null) { - return canonical; + `if (payload.containsKey(canonicalKey)) { + return payload[canonicalKey]; } final legacy = payload[legacyKey];`, `final legacy = payload[legacyKey]; if (legacy != null) { return legacy; } - final canonical = payload[canonicalKey];`, + return payload[canonicalKey];`, ); const result = inspectFlutterCanonicalExpression( legacyFirst, @@ -98,8 +96,9 @@ dynamic _canonicalOrLegacy( required String canonicalKey, required String legacyKey, }) { - // final canonical = payload[canonicalKey]; - // if (canonical != null) { return canonical; } + // if (payload.containsKey(canonicalKey)) { + // return payload[canonicalKey]; + // } final legacy = payload[legacyKey]; return legacy; } @@ -119,8 +118,8 @@ dynamic _canonicalOrLegacy( test("Flutter canonical helper rejects an early return before canonical data", () => { const earlyReturn = canonicalHelper.replace( - "final canonical = payload[canonicalKey];", - "if (payload.isEmpty) return legacy;\n final canonical = payload[canonicalKey];", + "if (payload.containsKey(canonicalKey)) {", + "if (payload.isEmpty) return legacy;\n if (payload.containsKey(canonicalKey)) {", ); const result = inspectFlutterCanonicalExpression( earlyReturn, @@ -152,6 +151,35 @@ test("a fallback before the helper remains the first payload source", () => { }); }); +test("Flutter canonical inspection follows own-key presence selectors", () => { + const functionBody = ` + final hasSourceId = sourcePayload.containsKey('id'); + final sourceId = sourcePayload['id']?.toString(); + final purchaseId = hasSourceId ? sourceId : null; + `; + const result = inspectFlutterCanonicalExpression( + canonicalHelper, + "purchaseId", + functionBody, + ); + + assert.deepEqual(result, { issues: [], sourceKey: "id" }); +}); + +test("Flutter canonical inspection follows transaction selection helper", () => { + const functionBody = ` + final transactionIdSelection = _transactionIdFrom(sourcePayload); + final sourceTransactionId = transactionIdSelection.value; + `; + const result = inspectFlutterCanonicalExpression( + canonicalHelper, + "sourceTransactionId", + functionBody, + ); + + assert.deepEqual(result, { issues: [], sourceKey: "transactionId" }); +}); + test("Kotlin payload parsing ignores decoys and nested commas", () => { const source = ` // return PurchaseAndroid(fake = "comment, decoy") From 82817f14120ba2c775d173b6a94725ec4d470ab2 Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 25 Jul 2026 01:22:46 +0900 Subject: [PATCH 11/16] fix: harden payload integrity edge cases --- .../src/__tests__/vega-adapter.test.ts | 28 ++ libraries/expo-iap/src/vega-adapter.ts | 15 +- .../src/__tests__/vega-adapter.test.ts | 28 ++ .../react-native-iap/src/vega-adapter.ts | 15 +- .../docs/src/pages/docs/updates/releases.tsx | 4 + scripts/audit-purchase-payload-parity.mjs | 294 +++++++++++++--- .../audit-purchase-payload-parity.test.mjs | 329 ++++++++++++++++++ 7 files changed, 660 insertions(+), 53 deletions(-) diff --git a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts index d17c713d5..e081d2a05 100644 --- a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts @@ -1090,6 +1090,34 @@ describe('Amazon Vega Expo adapter', () => { ]); }); + 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, + }), + ]); + }); + it('verifies Vega receipts through IAPKit Amazon payload', async () => { const service = createService(); const originalFetch = globalThis.fetch; diff --git a/libraries/expo-iap/src/vega-adapter.ts b/libraries/expo-iap/src/vega-adapter.ts index 076ec7700..99244946b 100644 --- a/libraries/expo-iap/src/vega-adapter.ts +++ b/libraries/expo-iap/src/vega-adapter.ts @@ -560,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( @@ -703,13 +708,15 @@ function mapReceipt( const type = productTypeToOpenIap(receipt.productType ?? fallbackProductType); const isCanceled = Boolean(receipt.isCancelled || receipt.cancelDate); const isActive = !isCanceled; + const deferredSku = nonBlankString(receipt.deferredSku); return { id: receiptId, productId, transactionDate: toTimestamp(receipt.purchaseDate), purchaseToken: receiptId, - currentPlanId: type === 'subs' ? (receipt.termSku ?? productId) : null, + currentPlanId: + type === 'subs' ? (nonBlankString(receipt.termSku) ?? productId) : null, ids: productId ? [productId] : [], platform: 'android', store: 'amazon', @@ -727,8 +734,8 @@ function mapReceipt( developerPayloadAndroid: null, isSuspendedAndroid: false, pendingPurchaseUpdateAndroid: - receipt.isDeferred && receipt.deferredSku - ? {products: [receipt.deferredSku], purchaseToken: receiptId} + receipt.isDeferred && deferredSku + ? {products: [deferredSku], purchaseToken: receiptId} : null, }; } 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 dfa18e92e..1a9250584 100644 --- a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts @@ -1181,6 +1181,34 @@ describe('Amazon Vega adapter', () => { ); }); + 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, + }), + ]); + }); + it('verifies Vega receipts through IAPKit Amazon payload', async () => { const service = createService(); const originalFetch = globalThis.fetch; diff --git a/libraries/react-native-iap/src/vega-adapter.ts b/libraries/react-native-iap/src/vega-adapter.ts index 8b424c9f7..6a52ae4af 100644 --- a/libraries/react-native-iap/src/vega-adapter.ts +++ b/libraries/react-native-iap/src/vega-adapter.ts @@ -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( @@ -714,6 +719,7 @@ function mapReceipt( const type = productTypeToOpenIap(receipt.productType ?? fallbackProductType); const isCanceled = Boolean(receipt.isCancelled || receipt.cancelDate); const isActive = !isCanceled; + const deferredSku = nonBlankString(receipt.deferredSku); return { id: receiptId, @@ -721,7 +727,8 @@ function mapReceipt( productId, transactionDate: toTimestamp(receipt.purchaseDate), purchaseToken: receiptId, - currentPlanId: type === 'subs' ? (receipt.termSku ?? productId) : null, + currentPlanId: + type === 'subs' ? (nonBlankString(receipt.termSku) ?? productId) : null, ids: productId ? [productId] : [], platform: 'android', store: 'amazon', @@ -740,8 +747,8 @@ function mapReceipt( developerPayloadAndroid: null, isSuspendedAndroid: false, pendingPurchaseUpdateAndroid: - receipt.isDeferred && receipt.deferredSku - ? {products: [receipt.deferredSku], purchaseToken: receiptId} + receipt.isDeferred && deferredSku + ? {products: [deferredSku], purchaseToken: receiptId} : null, }; } diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 9b4c6523d..648ed7ccf 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -202,6 +202,10 @@ function Releases() { key="cross-sdk-payload-integrity-planned-2026-07-24" style={noteCardStyle} > +