/** * Purchase event handling — extracted from OneSubProvider so the core logic * is directly unit-testable without rendering React. * * The Provider owns lifecycle (mount/unmount, React state) and wires these * helpers into `purchaseUpdatedListener`. Every unit of behavior here is * pure except for the injected dependencies. */ import type { OneSubConfig, SubscriptionInfo, PurchaseType, OneSubErrorCode } from '@onesub/shared'; import type { SdkLogger } from './logger.js'; export type InFlightEntry = { kind: 'subscription' | 'purchase'; purchaseType?: 'consumable' | 'non_consumable'; resolve: (value: unknown) => void; reject: (err: Error) => void; /** Handle of the registration timeout — cleared on settle or clearInFlight. */ timer?: ReturnType; }; export type Platform = 'ios' | 'android'; /** * Dependencies the event handler needs. Injecting rather than importing makes * the handler trivially mockable in tests. */ export interface PurchaseFlowDeps { config: OneSubConfig; userId: string; platform: Platform; inFlight: Map; RNIap: any; api: { validateReceipt: (serverUrl: string, body: any) => Promise; validatePurchase: (serverUrl: string, body: any) => Promise; }; onSubscriptionActivated?: (subscription: SubscriptionInfo) => void; isCancelled?: () => boolean; logger?: SdkLogger; /** * When false, this event is treated as an orphan replay no matter what — * in-flight matching is suppressed. Used during the mount drain window to * prevent a queued StoreKit redelivery from resolving a user-initiated * promise that happens to target the same productId. * * Rationale: StoreKit's `Transaction.updates` may deliver pending * transactions asynchronously in the first few hundred milliseconds after * listener attach. If the user taps Subscribe during that window, the * in-flight entry is already registered and would match the replay — the * classic "no sheet, immediately restored" bug. * * Default (undefined) is treated as true (matching enabled). */ allowInFlightMatching?: () => boolean; } export declare function extractReceiptToken(purchase: unknown): string; /** * Pull the store transaction id from a raw react-native-iap purchase object. * On iOS this is the StoreKit transactionId; on Android the Google order id. * v15 / OpenIAP surfaces `transactionId`; `id` is the newer OpenIAP alias and * `orderId` the legacy Android field — try them in order. Returns '' if none. */ export declare function extractTransactionId(purchase: unknown): string; /** * Decide whether this event describes a subscription. Priority: * 1. The caller's in-flight entry (user just tapped Subscribe vs Purchase) * 2. The Purchase object's `productType` field set by react-native-iap v15 * * Orphan replay events without in-flight MUST rely on (2) — we can't guess. * Falling back to "subscription" would cause validatePurchase-bound consumables * to hit the subscription validator. */ export declare function isSubscriptionEvent(purchase: { productType?: unknown; }, inFlight: InFlightEntry | undefined): boolean; /** * Decide whether a non-subscription event is consumable. Priority: * 1. The caller's in-flight entry — a user-initiated `purchaseProduct()` said * so explicitly, and nothing beats that. * 2. `config.consumableProductIds` — the host's declaration, the only source * an ORPHAN REPLAY has. A store transaction carries no consumable flag. * 3. `non_consumable` — the historical default. * * Step 2 exists because guessing at step 3 is silently destructive for a * consumable: the server records the wrong `type` (host reconciliation by type * never finds the purchase — paid, never granted) and `finishTransaction` * acknowledges instead of consuming (on Android the SKU stays owned forever, so * the user cannot rebuy). Neither surfaces as an error. Hosts that sell * consumables must declare them; hosts that do not are unaffected. */ export declare function resolvePurchaseType(productId: string, inFlight: InFlightEntry | undefined, config: Pick): PurchaseType; /** * Process a single purchase event (either a fresh transaction or a replay * delivered by Transaction.updates at connection time). Validates with the * server, finishes the transaction on success, and resolves/rejects the * matching in-flight promise if one exists. * * ORPHAN events (no in-flight entry) are legitimate — they happen when the * StoreKit queue had unfinished transactions at mount. We still validate + * finish them; the server idempotency (`action: 'restored'`) keeps this safe. * Updates to `isActive` happen via `onSubscriptionActivated`. */ export declare function handlePurchaseEvent(purchase: any, deps: PurchaseFlowDeps): Promise; /** * Register an in-flight slot for a productId and return a promise that * resolves when `handlePurchaseEvent` sees the matching event. */ export declare function registerInFlight(inFlight: Map, productId: string, kind: 'subscription' | 'purchase', purchaseType: 'consumable' | 'non_consumable' | undefined, timeoutMs?: number): Promise; /** * Remove an in-flight slot WITHOUT settling its promise, clearing the * registration timeout so the stale timer can't fire later. Use this on the * paths that abandon a registration (e.g. requestPurchase threw before any * store event could arrive) — settling paths go through entry.resolve/reject, * which clear the timer themselves. */ export declare function clearInFlight(inFlight: Map, productId: string): void; /** True for both legacy RN-IAP E_* and v15/OpenIAP normalized cancel codes. */ export declare function isUserCancelledNativeCode(code: unknown): boolean; /** True when RN-IAP reports that a one-time product is already present. */ export declare function isAlreadyOwnedNativeCode(code: unknown): boolean; /** * RN-IAP emitted the same purchase update twice and deliberately skipped the * second delivery. This is not an ownership error: the first update is already * being validated and must be allowed to settle the in-flight promise. */ export declare function isDuplicatePurchaseNativeCode(code: unknown): boolean; /** Keep a busy SDK operation distinct from cancel/no-purchase null outcomes. */ export declare function assertIapOperationAvailable(isBusy: boolean): void; /** * RN-IAP 15 can emit queued StoreKit transactions while `initConnection()` is * still resolving. Register first, then retry registration after init for the * rare Nitro-not-ready path where the pre-init JS subscription was inert. * * Only an explicit `false` counts as a failed connection. Some react-native-iap * builds resolve `undefined` on success, and tearing the listeners down there * would kill purchasing on a perfectly healthy connection; a real failure * rejects rather than resolving falsy. */ export declare function initializeIapConnectionWithListeners(attachListeners: () => void, initConnection: () => Promise, isCancelled?: () => boolean): Promise; /** * Convert a native purchase error into OneSub's stable public error contract. * `already-owned` is only canonicalized for non-consumables: treating a * consumable duplicate event as ownership would incorrectly send callers down * a restore path for a product that must be consumed instead. */ export declare function mapNativePurchaseErrorCode(err: unknown, entry?: Pick): OneSubErrorCode; /** * True when the error represents the user dismissing the purchase sheet — * either a raw react-native-iap error code or the SDK's own OneSubError * (the Provider's purchaseErrorListener rejects in-flight promises with * ONESUB_ERROR_CODE.USER_CANCELLED). Cancels are a normal outcome, not a * failure — callers return instead of throwing. */ export declare function isUserCancelled(err: unknown): boolean; //# sourceMappingURL=purchaseFlow.d.ts.map