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
- {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}
>
+
0) {
+ if (text.startsWith("/*", index)) {
+ depth += 1;
+ index += 2;
+ continue;
+ }
+ if (text.startsWith("*/", index)) {
+ depth -= 1;
+ index += 2;
+ continue;
+ }
+ index += 1;
+ }
+ return index;
+}
+
+function skipLineComment(text, start) {
+ const end = text.indexOf("\n", start + 2);
+ return end < 0 ? text.length : end;
+}
+
+function maskDelimitedLiteral(text, start, end, delimiter) {
+ const literal = text.slice(start, end);
+ const closed = literal.endsWith(delimiter);
+ const closingStart = closed ? literal.length - delimiter.length : Infinity;
+ let masked = "";
+ for (let index = 0; index < literal.length; index += 1) {
+ const char = literal[index];
+ masked +=
+ char === "\n" || index < delimiter.length || index >= closingStart
+ ? char
+ : " ";
+ }
+ return masked;
+}
+
+function kotlinStringDescriptorAt(text, index) {
+ if (text.startsWith('"""', index)) {
+ return { delimiter: '"""', interpolates: true, raw: true };
+ }
+ if (text[index] === '"' || text[index] === "'") {
+ return { delimiter: text[index], interpolates: true, raw: false };
+ }
+ if (text[index] === "`") {
+ return { delimiter: "`", interpolates: false, raw: true };
+ }
+ return null;
+}
+
+function findKotlinInterpolationEnd(text, start) {
+ let depth = 1;
+ let index = start;
+ while (index < text.length && depth > 0) {
+ if (text.startsWith("//", index)) {
+ index = skipLineComment(text, index);
+ continue;
+ }
+ if (text.startsWith("/*", index)) {
+ index = skipNestedBlockComment(text, index);
+ continue;
+ }
+ const descriptor = kotlinStringDescriptorAt(text, index);
+ if (descriptor) {
+ index = findKotlinStringEnd(text, index, descriptor);
+ continue;
+ }
+ if (text[index] === "{") depth += 1;
+ else if (text[index] === "}") depth -= 1;
+ index += 1;
+ }
+ return index;
+}
+
+function findKotlinStringEnd(text, start, descriptor) {
+ const { delimiter, interpolates, raw } = descriptor;
+ let index = start + delimiter.length;
+ while (index < text.length) {
+ if (text.startsWith(delimiter, index)) {
+ return index + delimiter.length;
+ }
+ if (!raw && text[index] === "\\") {
+ index += 2;
+ continue;
+ }
+ if (interpolates && text[index] === "$" && text[index + 1] === "{") {
+ index = findKotlinInterpolationEnd(text, index + 2);
+ continue;
+ }
+ index += 1;
+ }
+ return text.length;
+}
+
function maskKotlinCommentsAndStrings(text) {
let masked = "";
let state = "code";
+ let blockDepth = 0;
let index = 0;
while (index < text.length) {
const char = text[index];
@@ -78,11 +176,18 @@ function maskKotlinCommentsAndStrings(text) {
}
if (char === "/" && next === "*") {
state = "block";
+ blockDepth = 1;
masked += " ";
index += 2;
continue;
}
- if (char === '"') state = "string";
+ const descriptor = kotlinStringDescriptorAt(text, index);
+ if (descriptor) {
+ const end = findKotlinStringEnd(text, index, descriptor);
+ masked += maskDelimitedLiteral(text, index, end, descriptor.delimiter);
+ index = end;
+ continue;
+ }
masked += char;
index += 1;
continue;
@@ -94,8 +199,15 @@ function maskKotlinCommentsAndStrings(text) {
continue;
}
if (state === "block") {
+ if (char === "/" && next === "*") {
+ blockDepth += 1;
+ masked += " ";
+ index += 2;
+ continue;
+ }
if (char === "*" && next === "/") {
- state = "code";
+ blockDepth -= 1;
+ if (blockDepth === 0) state = "code";
masked += " ";
index += 2;
continue;
@@ -104,24 +216,79 @@ function maskKotlinCommentsAndStrings(text) {
index += 1;
continue;
}
- if (char === "\\") {
- masked += " ";
+ }
+ return masked;
+}
+
+function hasDartRawStringPrefix(text, quoteIndex) {
+ const prefix = text[quoteIndex - 1];
+ const beforePrefix = text[quoteIndex - 2];
+ return (
+ (prefix === "r" || prefix === "R") &&
+ (beforePrefix === undefined || !/[A-Za-z0-9_$]/.test(beforePrefix))
+ );
+}
+
+function dartStringDescriptorAt(text, index) {
+ const char = text[index];
+ if (char !== "'" && char !== '"') return null;
+ return {
+ delimiter: text.startsWith(char.repeat(3), index) ? char.repeat(3) : char,
+ raw: hasDartRawStringPrefix(text, index),
+ };
+}
+
+function findDartInterpolationEnd(text, start) {
+ let depth = 1;
+ let index = start;
+ while (index < text.length && depth > 0) {
+ if (text.startsWith("//", index)) {
+ index = skipLineComment(text, index);
+ continue;
+ }
+ if (text.startsWith("/*", index)) {
+ index = skipNestedBlockComment(text, index);
+ continue;
+ }
+ const descriptor = dartStringDescriptorAt(text, index);
+ if (descriptor) {
+ index = findDartStringEnd(text, index, descriptor);
+ continue;
+ }
+ if (text[index] === "{") depth += 1;
+ else if (text[index] === "}") depth -= 1;
+ index += 1;
+ }
+ return index;
+}
+
+function findDartStringEnd(text, start, descriptor) {
+ const { delimiter, raw } = descriptor;
+ let index = start + delimiter.length;
+ while (index < text.length) {
+ if (text.startsWith(delimiter, index)) {
+ return index + delimiter.length;
+ }
+ if (!raw && text[index] === "\\") {
index += 2;
continue;
}
- if (char === '"') state = "code";
- masked += char === '"' ? '"' : " ";
+ if (!raw && text[index] === "$" && text[index + 1] === "{") {
+ index = findDartInterpolationEnd(text, index + 2);
+ continue;
+ }
index += 1;
}
- return masked;
+ return text.length;
}
-// Dart uses both single- and double-quoted strings. Mask both while preserving
-// indices so structural scans cannot be confused by delimiters in literals.
+// Dart supports raw, multiline, and interpolated strings plus nested block
+// comments. Mask them while preserving indices so delimiters inside literals
+// and comments cannot confuse structural scans.
function maskDartCommentsAndStrings(text) {
let masked = "";
let state = "code";
- let quote = "";
+ let blockDepth = 0;
let index = 0;
while (index < text.length) {
const char = text[index];
@@ -135,13 +302,17 @@ function maskDartCommentsAndStrings(text) {
}
if (char === "/" && next === "*") {
state = "block";
+ blockDepth = 1;
masked += " ";
index += 2;
continue;
}
- if (char === "'" || char === '"') {
- state = "string";
- quote = char;
+ const descriptor = dartStringDescriptorAt(text, index);
+ if (descriptor) {
+ const end = findDartStringEnd(text, index, descriptor);
+ masked += maskDelimitedLiteral(text, index, end, descriptor.delimiter);
+ index = end;
+ continue;
}
masked += char;
index += 1;
@@ -154,8 +325,15 @@ function maskDartCommentsAndStrings(text) {
continue;
}
if (state === "block") {
+ if (char === "/" && next === "*") {
+ blockDepth += 1;
+ masked += " ";
+ index += 2;
+ continue;
+ }
if (char === "*" && next === "/") {
- state = "code";
+ blockDepth -= 1;
+ if (blockDepth === 0) state = "code";
masked += " ";
index += 2;
continue;
@@ -164,31 +342,60 @@ function maskDartCommentsAndStrings(text) {
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 findTypeScriptLiteralRanges(text) {
+ const sourceFile = ts.createSourceFile(
+ "payload-audit.ts",
+ text,
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TS,
+ );
+ const ranges = [];
+ function visit(node) {
+ if (
+ node.kind === ts.SyntaxKind.RegularExpressionLiteral ||
+ node.kind === ts.SyntaxKind.StringLiteral ||
+ node.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral ||
+ node.kind === ts.SyntaxKind.TemplateExpression ||
+ node.kind === ts.SyntaxKind.TemplateLiteralType
+ ) {
+ ranges.push([node.getStart(sourceFile), node.end]);
+ return;
+ }
+ ts.forEachChild(node, visit);
+ }
+ visit(sourceFile);
+ return ranges.sort(([left], [right]) => left - right);
+}
+
function maskTypeScriptCommentsAndStrings(text) {
+ const literalRanges = findTypeScriptLiteralRanges(text);
+ let literalRangeIndex = 0;
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") {
+ while (
+ literalRanges[literalRangeIndex] &&
+ literalRanges[literalRangeIndex][0] < index
+ ) {
+ literalRangeIndex += 1;
+ }
+ const literalRange = literalRanges[literalRangeIndex];
+ if (literalRange?.[0] === index) {
+ const [start, end] = literalRange;
+ masked += text.slice(start, end).replace(/[^\n]/g, " ");
+ index = end;
+ literalRangeIndex += 1;
+ continue;
+ }
if (char === "/" && next === "/") {
state = "line";
masked += " ";
@@ -201,10 +408,6 @@ function maskTypeScriptCommentsAndStrings(text) {
index += 2;
continue;
}
- if (char === "'" || char === '"' || char === "`") {
- state = "string";
- quote = char;
- }
masked += char;
index += 1;
continue;
@@ -226,18 +429,6 @@ function maskTypeScriptCommentsAndStrings(text) {
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;
}
@@ -1797,7 +1988,7 @@ function checkVegaPurchasePayloadContracts() {
expectNamedExpression(
entries,
"currentPlanId",
- /^type === ['"]subs['"] \? \(receipt\.termSku \?\? productId\) : null$/,
+ /^type === ['"]subs['"] \? \(nonBlankString\(receipt\.termSku\) \?\? productId\) : null$/,
`${relativePath} mapReceipt`,
);
expectNamedExpression(
@@ -1809,9 +2000,13 @@ function checkVegaPurchasePayloadContracts() {
const pendingUpdate = normalizeExpression(
entries.get("pendingPurchaseUpdateAndroid") ?? "",
);
+ const deferredSkuDeclaration = normalizeExpression(
+ mapper.body.match(/\bconst\s+deferredSku\s*=\s*([^;]+);/)?.[1] ?? "",
+ );
if (
!pendingUpdate.includes("receipt.isDeferred") ||
- !pendingUpdate.includes("receipt.deferredSku") ||
+ !pendingUpdate.includes("deferredSku") ||
+ deferredSkuDeclaration !== "nonBlankString(receipt.deferredSku)" ||
pendingUpdate.includes("receipt.termSku")
) {
fail(
@@ -1934,6 +2129,15 @@ export function inspectNamedArguments(
return { entries, issues };
}
+export function inspectDartMapEntries(mapBody) {
+ const previousFailures = failures;
+ failures = [];
+ const entries = parseDartMapEntries(mapBody, "Dart map fixture");
+ const issues = [...failures];
+ failures = previousFailures;
+ return { entries, issues };
+}
+
export function inspectMappedGeneratedFields(
generatedFields,
mappedFields,
diff --git a/scripts/audit-purchase-payload-parity.test.mjs b/scripts/audit-purchase-payload-parity.test.mjs
index 9c95ad30e..e1025d43d 100644
--- a/scripts/audit-purchase-payload-parity.test.mjs
+++ b/scripts/audit-purchase-payload-parity.test.mjs
@@ -1,9 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
+ extractBalancedAfterMarker,
+ inspectDartMapEntries,
inspectFlutterCanonicalExpression,
inspectMappedGeneratedFields,
inspectNamedArguments,
+ maskKotlinCommentsAndStrings,
+ maskTypeScriptCommentsAndStrings,
} from "./audit-purchase-payload-parity.mjs";
const canonicalHelper = `
@@ -206,6 +210,97 @@ test("Kotlin payload parsing ignores decoys and nested commas", () => {
);
});
+test("Dart payload parsing ignores multiline string delimiters", () => {
+ for (const literal of [
+ '"emoji 😀, } text"',
+ '"""raw " ) , } text"""',
+ "'''raw ' ) , } text'''",
+ 'r"""raw \\\\ " ) , } text"""',
+ "r'''raw \\\\ ' ) , } text'''",
+ ]) {
+ const source = `
+ return PurchaseAndroid(
+ id: payload.id,
+ dataAndroid: ${literal},
+ productId: payload.productId,
+ )
+ `;
+ const result = inspectNamedArguments(
+ source,
+ "return PurchaseAndroid",
+ ":",
+ "dart",
+ );
+
+ assert.deepEqual(result.issues, []);
+ assert.deepEqual([...result.entries.keys()].sort(), [
+ "dataAndroid",
+ "id",
+ "productId",
+ ]);
+ }
+});
+
+test("Dart payload parsing ignores nested block comments", () => {
+ const source = `
+ return PurchaseAndroid(
+ id: payload.id,
+ /* outer /* inner */ ) bogus: value, } still outer */
+ productId: payload.productId,
+ )
+ `;
+ const result = inspectNamedArguments(
+ source,
+ "return PurchaseAndroid",
+ ":",
+ "dart",
+ );
+
+ assert.deepEqual(result.issues, []);
+ assert.deepEqual([...result.entries.keys()].sort(), ["id", "productId"]);
+});
+
+test("Dart payload parsing ignores nested strings in interpolation", () => {
+ for (const expression of ['"${"}"} ) text"', '"""${\'"""\'} ) text"""']) {
+ const source = [
+ "return PurchaseAndroid(",
+ " id: payload.id,",
+ ` dataAndroid: ${expression},`,
+ " productId: payload.productId,",
+ ")",
+ ].join("\n");
+ const result = inspectNamedArguments(
+ source,
+ "return PurchaseAndroid",
+ ":",
+ "dart",
+ );
+
+ assert.deepEqual(result.issues, []);
+ assert.deepEqual([...result.entries.keys()].sort(), [
+ "dataAndroid",
+ "id",
+ "productId",
+ ]);
+ }
+});
+
+test("Dart map parsing preserves quoted key boundaries", () => {
+ const result = inspectDartMapEntries(`
+ 'id': purchaseId,
+ 'productId': productId,
+ 'dataAndroid': """raw " ) , } text""",
+ `);
+
+ assert.deepEqual(result.issues, []);
+ assert.deepEqual([...result.entries.keys()].sort(), [
+ "dataAndroid",
+ "id",
+ "productId",
+ ]);
+ assert.equal(result.entries.get("id")?.trim(), "purchaseId");
+});
+
test("generated mapping defaults remain tied to generated fields", () => {
assert.deepEqual(
inspectMappedGeneratedFields(
@@ -224,3 +319,237 @@ test("generated mapping defaults remain tied to generated fields", () => {
/unknown defaulted fields: inventedField/,
);
});
+
+test("TypeScript balanced extraction ignores braces in regex literals", () => {
+ const source = `
+ function mapPayload() {
+ const closingBrace = /}/;
+ return {id: payload.id};
+ }
+ `;
+ const region = extractBalancedAfterMarker(
+ source,
+ "function mapPayload",
+ "{",
+ "}",
+ "TypeScript regex fixture",
+ maskTypeScriptCommentsAndStrings,
+ );
+
+ assert.match(region?.body ?? "", /return\s+\{id:\s*payload\.id\};/);
+});
+
+test("TypeScript balanced extraction ignores regex literals after return", () => {
+ const source = `
+ function mapPayload() {
+ return /}/.test(payload.id) ? {id: payload.id} : {id: null};
+ }
+ `;
+ const region = extractBalancedAfterMarker(
+ source,
+ "function mapPayload",
+ "{",
+ "}",
+ "TypeScript return-regex fixture",
+ maskTypeScriptCommentsAndStrings,
+ );
+
+ assert.match(region?.body ?? "", /\?\s+\{id:\s*payload\.id\}/);
+ assert.match(region?.body ?? "", /:\s+\{id:\s*null\}/);
+});
+
+test("TypeScript regex masking preserves postfix division expressions", () => {
+ for (const expression of [
+ "count++ / scale",
+ "count-- / scale",
+ "count! / scale",
+ ]) {
+ const source = `
+ function mapPayload() {
+ const ratio = ${expression};
+ return {id: ratio};
+ }
+ `;
+ const region = extractBalancedAfterMarker(
+ source,
+ "function mapPayload",
+ "{",
+ "}",
+ `TypeScript division fixture: ${expression}`,
+ maskTypeScriptCommentsAndStrings,
+ );
+
+ assert.match(region?.body ?? "", /return\s+\{id:\s*ratio\}/);
+ }
+});
+
+test("TypeScript balanced extraction ignores regex literals after spread", () => {
+ const source = `
+ function mapPayload() {
+ const match = [.../}/.exec(payload.id)];
+ return {id: match[0]};
+ }
+ `;
+ const region = extractBalancedAfterMarker(
+ source,
+ "function mapPayload",
+ "{",
+ "}",
+ "TypeScript spread-regex fixture",
+ maskTypeScriptCommentsAndStrings,
+ );
+
+ assert.match(region?.body ?? "", /return\s+\{id:\s*match\[0\]\}/);
+});
+
+test("TypeScript balanced extraction ignores regex literals after statements", () => {
+ const source = `
+ function mapPayload() {
+ if (enabled) /}/.test(payload.id);
+ return {id: payload.id};
+ }
+ `;
+ const region = extractBalancedAfterMarker(
+ source,
+ "function mapPayload",
+ "{",
+ "}",
+ "TypeScript statement-regex fixture",
+ maskTypeScriptCommentsAndStrings,
+ );
+
+ assert.match(region?.body ?? "", /return\s+\{id:\s*payload\.id\}/);
+});
+
+test("TypeScript balanced extraction ignores nested template literals", () => {
+ const source = [
+ "function mapPayload() {",
+ ' const value = `${enabled ? `}` : "x"}`;',
+ " return {id: value};",
+ "}",
+ ].join("\n");
+ const region = extractBalancedAfterMarker(
+ source,
+ "function mapPayload",
+ "{",
+ "}",
+ "TypeScript nested-template fixture",
+ maskTypeScriptCommentsAndStrings,
+ );
+
+ assert.match(region?.body ?? "", /return\s+\{id:\s*value\}/);
+});
+
+test("TypeScript balanced extraction ignores template literal types", () => {
+ const source = [
+ "function mapPayload() {",
+ " type Value = `${T}}`;",
+ " return {id: 1};",
+ "}",
+ ].join("\n");
+ const region = extractBalancedAfterMarker(
+ source,
+ "function mapPayload",
+ "{",
+ "}",
+ "TypeScript template-type fixture",
+ maskTypeScriptCommentsAndStrings,
+ );
+
+ assert.match(region?.body ?? "", /return\s+\{id:\s*1\}/);
+});
+
+test("Kotlin balanced extraction ignores braces in character literals", () => {
+ const source = `
+ fun mapPayload(): PurchaseAndroid {
+ val closingBrace = '}'
+ return PurchaseAndroid(id = payload.id)
+ }
+ `;
+ const region = extractBalancedAfterMarker(
+ source,
+ "fun mapPayload",
+ "{",
+ "}",
+ "Kotlin character fixture",
+ maskKotlinCommentsAndStrings,
+ );
+
+ assert.match(region?.body ?? "", /return\s+PurchaseAndroid/);
+});
+
+test("Kotlin balanced extraction ignores braces in raw strings", () => {
+ const source = `
+ fun mapPayload(): PurchaseAndroid {
+ val raw = """raw " } content"""
+ return PurchaseAndroid(id = "payload")
+ }
+ `;
+ const region = extractBalancedAfterMarker(
+ source,
+ "fun mapPayload",
+ "{",
+ "}",
+ "Kotlin raw-string fixture",
+ maskKotlinCommentsAndStrings,
+ );
+
+ assert.match(region?.body ?? "", /return\s+PurchaseAndroid/);
+});
+
+test("Kotlin balanced extraction ignores nested strings in interpolation", () => {
+ const source = [
+ "fun mapPayload(): PurchaseAndroid {",
+ ' val value = "${if (enabled) "}" else "x"}"',
+ " return PurchaseAndroid(id = value)",
+ "}",
+ ].join("\n");
+ const region = extractBalancedAfterMarker(
+ source,
+ "fun mapPayload",
+ "{",
+ "}",
+ "Kotlin interpolation fixture",
+ maskKotlinCommentsAndStrings,
+ );
+
+ assert.match(region?.body ?? "", /return\s+PurchaseAndroid/);
+});
+
+test("Kotlin balanced extraction ignores braces in escaped identifiers", () => {
+ const source = [
+ "fun mapPayload(): PurchaseAndroid {",
+ " val `}` = 1",
+ " return PurchaseAndroid(id = payload.id)",
+ "}",
+ ].join("\n");
+ const region = extractBalancedAfterMarker(
+ source,
+ "fun mapPayload",
+ "{",
+ "}",
+ "Kotlin escaped-identifier fixture",
+ maskKotlinCommentsAndStrings,
+ );
+
+ assert.match(region?.body ?? "", /return\s+PurchaseAndroid/);
+});
+
+test("Kotlin balanced extraction ignores nested block comments", () => {
+ const source = `
+ fun mapPayload(): PurchaseAndroid {
+ /* outer /* inner */ } still outer */
+ return PurchaseAndroid(id = "payload")
+ }
+ `;
+ const region = extractBalancedAfterMarker(
+ source,
+ "fun mapPayload",
+ "{",
+ "}",
+ "Kotlin nested-comment fixture",
+ maskKotlinCommentsAndStrings,
+ );
+
+ assert.match(region?.body ?? "", /return\s+PurchaseAndroid/);
+});
From e8a3c0cb66d15b8601241816d58028118362b128 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sat, 25 Jul 2026 01:26:14 +0900
Subject: [PATCH 12/16] test: harden payload audit tokenization
---
scripts/audit-purchase-payload-parity.mjs | 39 ++++++++-----
.../audit-purchase-payload-parity.test.mjs | 55 +++++++++++++++++++
2 files changed, 80 insertions(+), 14 deletions(-)
diff --git a/scripts/audit-purchase-payload-parity.mjs b/scripts/audit-purchase-payload-parity.mjs
index 566a5b496..16f448601 100644
--- a/scripts/audit-purchase-payload-parity.mjs
+++ b/scripts/audit-purchase-payload-parity.mjs
@@ -63,6 +63,12 @@ function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+function isLineTerminator(char) {
+ return (
+ char === "\n" || char === "\r" || char === "\u2028" || char === "\u2029"
+ );
+}
+
function skipNestedBlockComment(text, start) {
let depth = 1;
let index = start + 2;
@@ -83,8 +89,11 @@ function skipNestedBlockComment(text, start) {
}
function skipLineComment(text, start) {
- const end = text.indexOf("\n", start + 2);
- return end < 0 ? text.length : end;
+ let index = start + 2;
+ while (index < text.length && !isLineTerminator(text[index])) {
+ index += 1;
+ }
+ return index;
}
function maskDelimitedLiteral(text, start, end, delimiter) {
@@ -95,7 +104,9 @@ function maskDelimitedLiteral(text, start, end, delimiter) {
for (let index = 0; index < literal.length; index += 1) {
const char = literal[index];
masked +=
- char === "\n" || index < delimiter.length || index >= closingStart
+ isLineTerminator(char) ||
+ index < delimiter.length ||
+ index >= closingStart
? char
: " ";
}
@@ -193,8 +204,8 @@ function maskKotlinCommentsAndStrings(text) {
continue;
}
if (state === "line") {
- if (char === "\n") state = "code";
- masked += char === "\n" ? "\n" : " ";
+ if (isLineTerminator(char)) state = "code";
+ masked += isLineTerminator(char) ? char : " ";
index += 1;
continue;
}
@@ -212,7 +223,7 @@ function maskKotlinCommentsAndStrings(text) {
index += 2;
continue;
}
- masked += char === "\n" ? "\n" : " ";
+ masked += isLineTerminator(char) ? char : " ";
index += 1;
continue;
}
@@ -319,8 +330,8 @@ function maskDartCommentsAndStrings(text) {
continue;
}
if (state === "line") {
- if (char === "\n") state = "code";
- masked += char === "\n" ? "\n" : " ";
+ if (isLineTerminator(char)) state = "code";
+ masked += isLineTerminator(char) ? char : " ";
index += 1;
continue;
}
@@ -338,7 +349,7 @@ function maskDartCommentsAndStrings(text) {
index += 2;
continue;
}
- masked += char === "\n" ? "\n" : " ";
+ masked += isLineTerminator(char) ? char : " ";
index += 1;
continue;
}
@@ -391,7 +402,7 @@ function maskTypeScriptCommentsAndStrings(text) {
const literalRange = literalRanges[literalRangeIndex];
if (literalRange?.[0] === index) {
const [start, end] = literalRange;
- masked += text.slice(start, end).replace(/[^\n]/g, " ");
+ masked += text.slice(start, end).replace(/[^\r\n\u2028\u2029]/g, " ");
index = end;
literalRangeIndex += 1;
continue;
@@ -413,8 +424,8 @@ function maskTypeScriptCommentsAndStrings(text) {
continue;
}
if (state === "line") {
- if (char === "\n") state = "code";
- masked += char === "\n" ? "\n" : " ";
+ if (isLineTerminator(char)) state = "code";
+ masked += isLineTerminator(char) ? char : " ";
index += 1;
continue;
}
@@ -425,7 +436,7 @@ function maskTypeScriptCommentsAndStrings(text) {
index += 2;
continue;
}
- masked += char === "\n" ? "\n" : " ";
+ masked += isLineTerminator(char) ? char : " ";
index += 1;
continue;
}
@@ -1059,7 +1070,7 @@ function swiftDictionarySourceReferences(expression) {
continue;
}
if (state === "line") {
- if (char === "\n") state = "code";
+ if (isLineTerminator(char)) state = "code";
index += 1;
continue;
}
diff --git a/scripts/audit-purchase-payload-parity.test.mjs b/scripts/audit-purchase-payload-parity.test.mjs
index e1025d43d..6920c023d 100644
--- a/scripts/audit-purchase-payload-parity.test.mjs
+++ b/scripts/audit-purchase-payload-parity.test.mjs
@@ -553,3 +553,58 @@ test("Kotlin balanced extraction ignores nested block comments", () => {
assert.match(region?.body ?? "", /return\s+PurchaseAndroid/);
});
+
+test("line comments terminate on non-LF line separators", () => {
+ for (const separator of ["\r", "\u2028", "\u2029"]) {
+ const kotlin = [
+ "fun mapPayload(): PurchaseAndroid {",
+ "// comment }",
+ 'return PurchaseAndroid(id = "payload")',
+ "}",
+ ].join(separator);
+ const kotlinRegion = extractBalancedAfterMarker(
+ kotlin,
+ "fun mapPayload",
+ "{",
+ "}",
+ "Kotlin line-separator fixture",
+ maskKotlinCommentsAndStrings,
+ );
+ assert.match(kotlinRegion?.body ?? "", /return\s+PurchaseAndroid/);
+
+ const typescript = [
+ "function mapPayload() {",
+ "// comment }",
+ "return {id: 1};",
+ "}",
+ ].join(separator);
+ const typescriptRegion = extractBalancedAfterMarker(
+ typescript,
+ "function mapPayload",
+ "{",
+ "}",
+ "TypeScript line-separator fixture",
+ maskTypeScriptCommentsAndStrings,
+ );
+ assert.match(typescriptRegion?.body ?? "", /return\s+\{id:\s*1\}/);
+
+ const dart = [
+ "return PurchaseAndroid(",
+ "id: payload.id,",
+ "// comment )",
+ "productId: payload.productId,",
+ ")",
+ ].join(separator);
+ const dartResult = inspectNamedArguments(
+ dart,
+ "return PurchaseAndroid",
+ ":",
+ "dart",
+ );
+ assert.deepEqual(dartResult.issues, []);
+ assert.deepEqual([...dartResult.entries.keys()].sort(), [
+ "id",
+ "productId",
+ ]);
+ }
+});
From 6081cad3c638fac078de236425eebd7c85311e20 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sat, 25 Jul 2026 01:34:46 +0900
Subject: [PATCH 13/16] fix(ci): install parity audit dependencies
---
.github/workflows/ci.yml | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cd749f7b6..2ba57788c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -131,11 +131,13 @@ jobs:
with:
bun-version: 1.3.13
- - name: Install GQL audit dependencies
+ - name: Install parity audit dependencies
run: |
- # Retry bun install up to 3 times to handle transient registry errors
+ # The root parity scripts use root devDependencies (for example,
+ # TypeScript) as well as GQL workspace tooling. Install the complete
+ # frozen workspace so Node can resolve both dependency scopes.
for i in 1 2 3; do
- bun install --frozen-lockfile --filter @hyodotdev/openiap-gql && break
+ bun install --frozen-lockfile && break
[ $i -eq 3 ] && exit 1
echo "Attempt $i failed. Retrying..."
sleep 5
From d996b675dd1b541b9941da2c317ef09001793cf3 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sat, 25 Jul 2026 01:35:56 +0900
Subject: [PATCH 14/16] perf(ci): scope parity audit install
---
.github/workflows/ci.yml | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2ba57788c..f7828d735 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -134,10 +134,12 @@ jobs:
- name: Install parity audit dependencies
run: |
# The root parity scripts use root devDependencies (for example,
- # TypeScript) as well as GQL workspace tooling. Install the complete
- # frozen workspace so Node can resolve both dependency scopes.
+ # TypeScript) as well as GQL workspace tooling. Install both scopes
+ # so Node can resolve them without installing unrelated workspaces.
for i in 1 2 3; do
- bun install --frozen-lockfile && break
+ bun install --frozen-lockfile \
+ --filter @hyodotdev/openiap \
+ --filter @hyodotdev/openiap-gql && break
[ $i -eq 3 ] && exit 1
echo "Attempt $i failed. Retrying..."
sleep 5
From be10efb01a8ebb3dce44e6495d13a55ed280f48e Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sat, 25 Jul 2026 01:39:53 +0900
Subject: [PATCH 15/16] test(gql): track scoped parity install
---
packages/gql/src/generated-sync-manifest.test.mjs | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/packages/gql/src/generated-sync-manifest.test.mjs b/packages/gql/src/generated-sync-manifest.test.mjs
index 6669b0e23..9a8358cee 100644
--- a/packages/gql/src/generated-sync-manifest.test.mjs
+++ b/packages/gql/src/generated-sync-manifest.test.mjs
@@ -184,16 +184,19 @@ describe("generated sync manifest", () => {
workflow.indexOf(" audit-parity:"),
workflow.indexOf("\n test-gql:"),
);
- const syncIndex = parityJob.indexOf("./scripts/sync-versions.sh");
- const driftIndex = parityJob.indexOf(
+ const normalizedParityJob = parityJob.replace(/\\\r?\n[ \t]*/g, "");
+ const syncIndex = normalizedParityJob.indexOf("./scripts/sync-versions.sh");
+ const driftIndex = normalizedParityJob.indexOf(
"node scripts/assert-clean-worktree.mjs",
);
- const parityIndex = parityJob.indexOf(
+ const parityIndex = normalizedParityJob.indexOf(
"node scripts/audit-non-godot-parity.mjs",
);
- const setupIndex = parityJob.indexOf("uses: oven-sh/setup-bun@v2");
- const installIndex = parityJob.indexOf(
- "bun install --frozen-lockfile --filter @hyodotdev/openiap-gql",
+ const setupIndex = normalizedParityJob.indexOf(
+ "uses: oven-sh/setup-bun@v2",
+ );
+ const installIndex = normalizedParityJob.indexOf(
+ "bun install --frozen-lockfile --filter @hyodotdev/openiap --filter @hyodotdev/openiap-gql",
);
expect(syncIndex).toBeGreaterThanOrEqual(0);
From 53b1ca468f8dbde8a9479d2f107eae320b169b6a Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sat, 25 Jul 2026 05:16:09 +0900
Subject: [PATCH 16/16] fix(expo): align unknown product query fallback
---
libraries/expo-iap/ios/ExpoIapHelper.swift | 2 +-
.../src/__tests__/canonical-key-presence.test.js | 11 +++++++++++
2 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/libraries/expo-iap/ios/ExpoIapHelper.swift b/libraries/expo-iap/ios/ExpoIapHelper.swift
index 74ffa47a3..bcb652f49 100644
--- a/libraries/expo-iap/ios/ExpoIapHelper.swift
+++ b/libraries/expo-iap/ios/ExpoIapHelper.swift
@@ -77,7 +77,7 @@ enum ExpoIapHelper {
case ProductQueryType.all.rawValue:
return .all
default:
- return .all
+ return .inApp
}
}
diff --git a/libraries/expo-iap/src/__tests__/canonical-key-presence.test.js b/libraries/expo-iap/src/__tests__/canonical-key-presence.test.js
index cadceb1d4..d77083bc5 100644
--- a/libraries/expo-iap/src/__tests__/canonical-key-presence.test.js
+++ b/libraries/expo-iap/src/__tests__/canonical-key-presence.test.js
@@ -17,6 +17,17 @@ describe('native canonical-key presence contract', () => {
expect(helper).toContain('request.removeValue(forKey: "ios")');
});
+ it('fails closed to in-app for unrecognized iOS product query types', () => {
+ const helper = readExpoFile('ios/ExpoIapHelper.swift');
+ const parser = helper.match(
+ /static func parseProductQueryType[\s\S]*?\n }\n\n static func decodeProductRequest/,
+ )?.[0];
+
+ expect(parser).toBeDefined();
+ expect(parser).toContain('default:\n return .inApp');
+ expect(parser).not.toContain('default:\n return .all');
+ });
+
it('does not let Onside fall through from an explicit apple key to ios', () => {
const onside = readExpoFile('ios/onside/OnsideIapModule.swift');