Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,15 @@ jobs:
with:
bun-version: 1.3.13

- name: Install GQL audit dependencies
- name: Install parity audit dependencies
run: |
# Retry bun install up to 3 times to handle transient registry errors
# The root parity scripts use root devDependencies (for example,
# TypeScript) as well as GQL workspace tooling. Install both scopes
# so Node can resolve them without installing unrelated workspaces.
for i in 1 2 3; do
bun install --frozen-lockfile --filter @hyodotdev/openiap-gql && break
bun install --frozen-lockfile \
--filter @hyodotdev/openiap \
--filter @hyodotdev/openiap-gql && break
[ $i -eq 3 ] && exit 1
echo "Attempt $i failed. Retrying..."
sleep 5
Expand Down
23 changes: 22 additions & 1 deletion knowledge/_claude-context/context.md
Original file line number Diff line number Diff line change
@@ -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`

Expand Down Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions knowledge/internal/04-platform-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,27 @@ If it fails for Godot GDAP dependency drift, run
`./libraries/godot-iap/scripts/write-gdap.sh` and commit the regenerated
`libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap`.

### Generated payload preservation

Generated payload types are additive contracts. Handwritten native and framework
bridges must preserve every canonical field rather than reconstructing
`Purchase`, `ActiveSubscription`, `RenewalInfoIOS`, or verification results from
local allowlists. Prefer the generated `toJson` / `fromJson` or canonical
serializer, recursively normalize platform dictionaries and `NSNull`, and add
only documented transport-specific fields around that generated payload.

Map canonical fields from their same-named native source before applying a
compatibility fallback. In particular, an orderless Google Play purchase keeps
`transactionId` null instead of copying `purchaseToken`, while alternative-store
deferred plan changes remain active purchases and expose
`pendingPurchaseUpdateAndroid` plus the current plan. Listener diagnostics must
never include raw purchase payloads, receipts, or tokens.

`bun run audit:parity` compares generated payload fields with the handwritten
bridges and exercises source-first mappings and round trips. When a generated
payload field or bridge changes, update the real platform mapping and a focused
regression fixture before extending the audit expectation.

### The bug pattern

A symptom like "interface exists in `types.dart` / `types.ts` / `Types.kt` but calling it does nothing / throws" means one or more of these layers is missing:
Expand Down
4 changes: 2 additions & 2 deletions libraries/expo-iap/ios/ExpoIapHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -77,7 +77,7 @@ enum ExpoIapHelper {
case ProductQueryType.all.rawValue:
return .all
default:
return .all
return .inApp
}
}

Expand Down
113 changes: 104 additions & 9 deletions libraries/expo-iap/ios/onside/OnsideIapModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -458,9 +468,29 @@ public final class ExpoIapOnsideModule: Module {
dictionary["displayPrice"] = formattedPrice
dictionary["currency"] = product.price.currencyCode
dictionary["price"] = priceNumber
dictionary["type"] = "in-app"
dictionary["typeIOS"] = "non-consumable"
let subscriptionPeriod = product.subscriptionPeriod.map {
subscriptionPeriodComponentsIOS($0)
}
let isSubscription = subscriptionPeriod != nil
dictionary["type"] = isSubscription ? "subs" : "in-app"
dictionary["typeIOS"] = isSubscription ? "auto-renewable-subscription" : "non-consumable"
dictionary["isFamilyShareableIOS"] = false
dictionary["subscriptionGroupIdIOS"] = product.subscriptionGroupIdentifier
if let subscriptionPeriod {
dictionary["subscriptionPeriodNumberIOS"] = String(subscriptionPeriod.value)
dictionary["subscriptionPeriodUnitIOS"] = subscriptionPeriod.unit
}
if let introductoryPrice = product.introductoryPrice {
let introductoryPeriod = subscriptionPeriodComponentsIOS(introductoryPrice.period)
dictionary["introductoryPriceAsAmountIOS"] = String(introductoryPrice.price.value)
dictionary["introductoryPriceIOS"] = formatPriceIOS(introductoryPrice.price)
dictionary["introductoryPriceNumberOfPeriodsIOS"] = String(introductoryPeriod.value)
dictionary["introductoryPricePaymentModeIOS"] =
introductoryPricePaymentModeIOS(for: introductoryPrice).rawValue
dictionary["introductoryPriceSubscriptionPeriodIOS"] = introductoryPeriod.unit
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else if isSubscription {
dictionary["introductoryPricePaymentModeIOS"] = PaymentModeIOS.empty.rawValue
}
// Avoid JSONEncoder on non-Encodable SDK type: build JSON string from known fields
dictionary["jsonRepresentationIOS"] = try makeProductJSONRepresentation(from: product)
dictionary["debugDescription"] = product.description
Expand All @@ -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)
Expand All @@ -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
}
Expand All @@ -501,7 +547,10 @@ public final class ExpoIapOnsideModule: Module {
priceFormatter.currencyCode = product.price.currencyCode
let priceNumber = makePriceNumber(from: product)
let formattedPrice = priceFormatter.string(from: priceNumber) ?? "\(product.price.value)"
let jsonObject: [String: Any] = [
let subscriptionPeriod = product.subscriptionPeriod.map {
subscriptionPeriodComponentsIOS($0)
}
var jsonObject: [String: Any] = [
"id": product.productIdentifier,
"title": product.localizedTitle,
"description": product.localizedDescription,
Expand All @@ -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")
Expand All @@ -525,6 +583,43 @@ public final class ExpoIapOnsideModule: Module {
NSDecimalNumber(string: String(product.price.value))
}

private func formatPriceIOS(_ price: OnsidePrice) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = price.currencyCode
let number = NSDecimalNumber(string: String(price.value))
return formatter.string(from: number) ?? "\(price.value)"
}

private func introductoryPricePaymentModeIOS(
for offer: OnsidePricePeriod
) -> PaymentModeIOS {
if offer.price.value == 0 {
return .freeTrial
}

// OnsideKit exposes only price and period for introductory offers, so
// paid offers cannot be distinguished as pay-as-you-go or pay-up-front.
return .empty
}

private func subscriptionPeriodComponentsIOS(
_ period: OnsidePeriod
) -> (value: Int, unit: String) {
switch period {
case .day(let value):
return (Int(value), "day")
case .week(let value):
return (Int(value), "week")
case .month(let value):
return (Int(value), "month")
case .year(let value):
return (Int(value), "year")
@unknown default:
return (0, "empty")
}
}

private func fallbackTransactionDate(for transaction: OnsidePaymentTransaction) -> Date {
let cacheKey = transaction.transactionIdentifier
?? transaction.originalTransactionIdentifier
Expand Down Expand Up @@ -553,9 +648,9 @@ public final class ExpoIapOnsideModule: Module {
case .purchased:
return "purchased"
case .restored:
return "restored"
return "purchased"
case .failed:
return "failed"
return "unknown"
case .purchasing:
return "pending"
@unknown default:
Expand Down
40 changes: 17 additions & 23 deletions libraries/expo-iap/src/ExpoIapModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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} {
Expand All @@ -28,9 +27,7 @@ function getResolved(): {module: any; name: NativeIapModuleName} {
return 'ExpoIapVega';
}

return shouldUseOnsideModule() && !onsideModuleUnavailable
? 'ExpoIapOnside'
: 'ExpoIap';
return shouldUseOnsideModule() ? 'ExpoIapOnside' : 'ExpoIap';
}

function resolveNativeModule(): {
Expand All @@ -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'),
Expand All @@ -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.',
);
}
}

Expand All @@ -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<string, unknown> = new Proxy(
{} as Record<string, unknown>,
{
Expand Down Expand Up @@ -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.`,
);
};
},
});
Loading
Loading