From 2fefc9f7aaa1102df6793ad5cc7410638efb7c28 Mon Sep 17 00:00:00 2001 From: Hyo Date: Mon, 29 Sep 2025 21:01:30 +0900 Subject: [PATCH 1/6] chore(deps): upgrade openiap-google to 1.2.10 --- openiap-versions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openiap-versions.json b/openiap-versions.json index 624345efe..89c907527 100644 --- a/openiap-versions.json +++ b/openiap-versions.json @@ -1,5 +1,5 @@ { "apple": "1.2.4", - "google": "1.2.9", + "google": "1.2.10", "gql": "1.0.9" } From 4357214ddb795283b6125ed8efaa69cd6cc49e7a Mon Sep 17 00:00:00 2001 From: Hyo Date: Mon, 29 Sep 2025 21:01:56 +0900 Subject: [PATCH 2/6] docs: update example code links --- docs/docs/examples/purchase-flow.md | 6 +----- docs/docs/examples/subscription-flow.md | 10 +--------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/docs/docs/examples/purchase-flow.md b/docs/docs/examples/purchase-flow.md index 5ee8831da..a5d7b2653 100644 --- a/docs/docs/examples/purchase-flow.md +++ b/docs/docs/examples/purchase-flow.md @@ -10,11 +10,7 @@ import AdFitTopFixed from "@site/src/uis/AdFitTopFixed"; -This example walks through a clean purchase flow using react-native-iap with the `useIAP` hook and the new platform‑specific request shape. It mirrors the working sample in `example/app/purchase-flow.tsx`. - -View the full example source: - -- GitHub: [example/app/purchase-flow.tsx](https://github.com/hyochan/react-native-iap/blob/main/example/app/purchase-flow.tsx) +This example walks through a clean purchase flow using react-native-iap with the `useIAP` hook and the new platform‑specific request shape. It mirrors the working sample in [example/screens/PurchaseFlow.tsx](https://github.com/hyochan/react-native-iap/blob/main/example/screens/PurchaseFlow.tsx). ## Flow Overview diff --git a/docs/docs/examples/subscription-flow.md b/docs/docs/examples/subscription-flow.md index 32f84c6d3..5d6ca5342 100644 --- a/docs/docs/examples/subscription-flow.md +++ b/docs/docs/examples/subscription-flow.md @@ -12,11 +12,7 @@ import AdFitTopFixed from "@site/src/uis/AdFitTopFixed"; -This example walks through a practical subscriptions flow with react-native-iap. It mirrors the working sample in `example/app/subscription-flow.tsx`, including status checks, renewal handling, and subscription management UI. - -View the full example source: - -- GitHub: [example/app/subscription-flow.tsx](https://github.com/hyochan/react-native-iap/blob/main/example/app/subscription-flow.tsx) +This example walks through a practical subscriptions flow with react-native-iap. It mirrors the working sample in [example/screens/SubscriptionFlow.tsx](https://github.com/hyochan/react-native-iap/blob/main/example/screens/SubscriptionFlow.tsx), including status checks, renewal handling, and subscription management UI. ## Important: Platform-Specific Subscription Properties @@ -43,10 +39,6 @@ When checking subscription status, different platforms provide different propert ## Complete Subscription Flow -View the full example source: - -- GitHub: [example/app/subscription-flow.tsx](https://github.com/hyochan/react-native-iap/blob/main/example/app/subscription-flow.tsx) - ```tsx import React, {useEffect, useState} from 'react'; import { From 65d1b2e5755d16f337eef0efe6d6a498cf380fca Mon Sep 17 00:00:00 2001 From: Hyo Date: Tue, 30 Sep 2025 00:05:26 +0900 Subject: [PATCH 3/6] chore(example): add sub upgrade/downgrade senario --- .../screens/SubscriptionFlow.test.tsx | 200 ++++++- example/ios/Podfile.lock | 122 ++-- example/screens/SubscriptionFlow.tsx | 548 ++++++++++++++++-- 3 files changed, 775 insertions(+), 95 deletions(-) diff --git a/example/__tests__/screens/SubscriptionFlow.test.tsx b/example/__tests__/screens/SubscriptionFlow.test.tsx index fe2bb3912..caf4514ac 100644 --- a/example/__tests__/screens/SubscriptionFlow.test.tsx +++ b/example/__tests__/screens/SubscriptionFlow.test.tsx @@ -8,13 +8,15 @@ const requestPurchaseMock = RNIap.requestPurchase as jest.Mock; const deepLinkToSubscriptionsMock = RNIap.deepLinkToSubscriptions as jest.Mock; const sampleSubscription = { - type: 'subs', + type: 'subs' as const, id: 'dev.hyo.martie.premium', title: 'Premium Subscription', description: 'Access all premium features', displayPrice: '$9.99/month', price: 9.99, currency: 'USD', + platform: 'android' as const, + nameAndroid: 'Premium Subscription', }; describe('SubscriptionFlow Screen', () => { @@ -175,4 +177,200 @@ describe('SubscriptionFlow Screen', () => { ).toBeTruthy(); }); }); + + it('handles upgrade/downgrade plan change for premium subscription', async () => { + const alertSpy = jest.spyOn(Alert, 'alert'); + + mockIapState({ + activeSubscriptions: [ + { + productId: 'dev.hyo.martie.premium', + transactionId: 'trans-1', + transactionDate: Date.now(), + isActive: true, + } as any, + ], + subscriptions: [ + { + ...sampleSubscription, + subscriptionOfferDetailsAndroid: [ + { + basePlanId: 'premium', + offerToken: 'offer-token-monthly', + offerTags: [], + pricingPhases: { + pricingPhaseList: [ + { + formattedPrice: '$9.99', + priceAmountMicros: '9990000', + priceCurrencyCode: 'USD', + billingPeriod: 'P1M', + billingCycleCount: 0, + recurrenceMode: 1, + }, + ], + }, + }, + { + basePlanId: 'premium-year', + offerToken: 'offer-token-yearly', + offerTags: [], + pricingPhases: { + pricingPhaseList: [ + { + formattedPrice: '$99.99', + priceAmountMicros: '99990000', + priceCurrencyCode: 'USD', + billingPeriod: 'P1Y', + billingCycleCount: 0, + recurrenceMode: 1, + }, + ], + }, + }, + ], + }, + ], + }); + + const {getByText} = render(); + + // Should show upgrade button for monthly plan + await waitFor(() => { + expect(getByText('⬆️ Upgrade to Yearly Plan')).toBeTruthy(); + }); + + // Press upgrade button + fireEvent.press(getByText('⬆️ Upgrade to Yearly Plan')); + + // Should show confirmation alert + expect(alertSpy).toHaveBeenCalledWith( + 'Change Subscription Plan', + expect.stringContaining('upgrade to Yearly'), + expect.any(Array), + ); + }); + + it('displays empty state when no subscriptions available', () => { + mockIapState({ + subscriptions: [], + }); + + const {getByText} = render(); + + expect( + getByText('No subscriptions found. Configure products in the console.'), + ).toBeTruthy(); + expect( + getByText('No subscriptions found. Please configure your products.'), + ).toBeTruthy(); + }); + + it('shows already subscribed for owned products', () => { + mockIapState({ + activeSubscriptions: [ + { + productId: 'dev.hyo.martie.premium', + } as any, + ], + }); + + const {getByText} = render(); + + // Button should show 'Already Subscribed' and be disabled + const button = getByText('Already Subscribed'); + expect(button).toBeTruthy(); + }); + + it('retries loading subscriptions when retry button pressed', async () => { + const {fetchProducts} = mockIapState({ + subscriptions: [], + }); + + const {getByText} = render(); + + fireEvent.press(getByText('Retry')); + + await waitFor(() => { + expect(fetchProducts).toHaveBeenCalledWith({ + skus: SUBSCRIPTION_PRODUCT_IDS, + type: 'subs', + }); + }); + }); + + it('handles connection state changes', () => { + mockIapState({ + connected: false, + }); + + const {getByText, rerender} = render(); + + expect(getByText('Connecting to Store...')).toBeTruthy(); + + // Simulate connection established + mockIapState({ + connected: true, + }); + + rerender(); + + expect(getByText('Available Subscriptions')).toBeTruthy(); + }); + + it('opens subscription details modal', async () => { + const {getByText} = render(); + + // Open subscription details modal + fireEvent.press(getByText('ℹ️')); + + await waitFor(() => { + expect(getByText('Subscription Details')).toBeTruthy(); + }); + + // Modal content should be displayed + expect(getByText('📋 Copy')).toBeTruthy(); + expect(getByText('🖥️ Console')).toBeTruthy(); + }); + + it('logs subscription data to console', async () => { + const consoleSpy = jest.spyOn(console, 'log'); + + const {getByText} = render(); + + // Open subscription details modal + fireEvent.press(getByText('ℹ️')); + + await waitFor(() => { + expect(getByText('Subscription Details')).toBeTruthy(); + }); + + // Log to console + fireEvent.press(getByText('🖥️ Console')); + + expect(consoleSpy).toHaveBeenCalledWith('=== SUBSCRIPTION DATA ==='); + expect(consoleSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'dev.hyo.martie.premium', + }), + ); + }); + + it('closes subscription details modal', async () => { + const {getByText, queryByText} = render(); + + // Open modal + fireEvent.press(getByText('ℹ️')); + + await waitFor(() => { + expect(getByText('Subscription Details')).toBeTruthy(); + }); + + // Close modal + fireEvent.press(getByText('✕')); + + await waitFor(() => { + expect(queryByText('Subscription Details')).toBeNull(); + }); + }); }); diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index b4579ae89..651313aab 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -8,7 +8,7 @@ PODS: - hermes-engine (0.81.1): - hermes-engine/Pre-built (= 0.81.1) - hermes-engine/Pre-built (0.81.1) - - NitroIap (14.4.7): + - NitroIap (14.4.8): - boost - DoubleConversion - fast_float @@ -2747,8 +2747,8 @@ SPEC CHECKSUMS: fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: 4f8246b1f6d79f625e0d99472d1f3a71da4d28ca - NitroIap: 5b47dc58f50387cadb18bce9d284ba8ff346031b - NitroModules: d9c969e83c30ec1e7efc95e0ae58c21db1585c14 + NitroIap: 4ff64b96fa367e07eb3d2d5b3eb0d64cd2c16b7c + NitroModules: 7d693306799405ca141ef5c24efc0936f20a09c0 openiap: bd45c90b44a0fb4bebaa9c2501598d76c253eb0f RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: c4b9e2fd0ab200e3af72b013ed6113187c607077 @@ -2756,69 +2756,69 @@ SPEC CHECKSUMS: RCTTypeSafety: 720403058b7c1380c6a3ae5706981d6362962c89 React: f1486d005993b0af01943af1850d3d4f3b597545 React-callinvoker: 133f69368c8559e744efa345223625d412f5dfbe - React-Core: d6d8c1fd33697cec596d33b820456505ee305686 - React-CoreModules: 81ab751a7668ba161440f9623b994e1a6a3019fe - React-cxxreact: 16f2a2751d0dce8b569f23c1914edc90f655b01b + React-Core: 559823921b4f294c2840fa8238ca958a29ddc211 + React-CoreModules: c41e7bbfabbc420783bb926f45837a0d5e53341e + React-cxxreact: 9cb9fa738274a1b36b97ede09c8a6717dec1a20b React-debug: e01581e1589f329e61c95b332bf7f4969b10564b - React-defaultsnativemodule: e956b1d8fe15cc79d23061db229bf88170565f2f - React-domnativemodule: a18b0f7a31b9c75f12fa369baece5542d1265b36 - React-Fabric: c0237a32c3c0dbea2d2b294c8e95605e1dfe2f57 - React-FabricComponents: 65b03884bd5d9f24c79a631d7d26f0fa079bc4aa - React-FabricImage: de1ea2f2a0b32ad02e5cbb64827d1eec0439cf0d - React-featureflags: 02de9c35256cc624269b01d670d99e1fd706ea8d - React-featureflagsnativemodule: 8b84e67edbaa7b9318390c5bd3ae19790a74f356 - React-graphics: 004b40c1b236ea3bb8de6693439bef9797922ba9 - React-hermes: 2179a018b2f86652f6f33ef23efd9e5ac284b247 - React-idlecallbacksnativemodule: f54ea68f984b12e42feed1e7110623b2c38df4d1 - React-ImageManager: 9dd04b7b62bc5397f876ca5fb1b712e700ce390c - React-jserrorhandler: 2f90bf50fffea1d012e7f3d717c6adf748b1813d - React-jsi: b27208f8866e53238534f65f304903e4eff25e05 - React-jsiexecutor: 1d3e827797f592c393860dea91aaa6d53c7715e7 - React-jsinspector: bda319277ae779bc476b736fe3a497c6aed304cd - React-jsinspectorcdp: 69e1736edfd5420037680b7b4557fa748c3c8216 - React-jsinspectornetwork: 7aa707b057c6129b4af59e0c9160436bbab25022 - React-jsinspectortracing: b4a8a328ad2697f9638daa4b7cc054e0303fa47f - React-jsitooling: a6c7e2829437b28665e97a398b3374d443125e24 - React-jsitracing: d87ae17dd0eef7844e605945da926c5433fe2b51 - React-logger: d27dd2000f520bf891d24f6e141cde34df41f0ee - React-Mapbuffer: 0746ffab5ac0f49b7c9347338e3d0c1d9dd634c8 - React-microtasksnativemodule: b0fb3f97372df39bda3e657536039f1af227cc29 - react-native-safe-area-context: 6d8a7b750e496e37bda47c938320bf2c734d441f - React-NativeModulesApple: 9ec9240159974c94886ebbe4caec18e3395f6aef + React-defaultsnativemodule: bbb39447caa6b6cf9405fa0099f828c083640faa + React-domnativemodule: 03744d12b6d56d098531a933730bf1d4cb79bdfb + React-Fabric: 530b3993a12a96e8a7cdb9f0ef48e605277b572e + React-FabricComponents: 271ec2a9b2c00ac66fd6d1fd24e9e964d907751d + React-FabricImage: d0af66e976dbab7f8b81e36dd369fc70727d2695 + React-featureflags: 269704c8eff86e0485c9d384e286350fcda6eb70 + React-featureflagsnativemodule: db1e5d88a912fb08a5ece33fcf64e1b732da8467 + React-graphics: b19d03a01b0722b4dc82f47acb56dc3ed41937e7 + React-hermes: 811606c0aca5a3f9c6fa8e4994e02ca8f677e68e + React-idlecallbacksnativemodule: 3a3df629cd50046c7e4354f9025aefe8f2c84601 + React-ImageManager: 0d53866c63132791e37bb2373f93044fdef14aa3 + React-jserrorhandler: d5700d6ab7162fd575287502a3c5d601d98e7f09 + React-jsi: ece95417fedbed0e7153a855cb8342b7c72ab75e + React-jsiexecutor: 2b0bb644b533df2f5c0cd6ade9a4560d0bf1dd84 + React-jsinspector: 0c160f8510a8852bdf2dac12f0b1949efc18200b + React-jsinspectorcdp: f4b84409f453f61ddd8614ad45139bc594ec6bb5 + React-jsinspectornetwork: 8f2f0ca8c871ca19b571f426002c0012e7fb2aee + React-jsinspectortracing: 33f6b977eb8a4bc1e3d1a4b948809aca083143f9 + React-jsitooling: 2c61529b589e17229a9f0a4a4fc35aa7ad495850 + React-jsitracing: 838a7b0c013c4aff7d382d7fdc78cf442013ba1d + React-logger: 7aef4d74123e5e3d267e5af1fbf5135b5a0d8381 + React-Mapbuffer: 91e0eab42a6ae7f3e34091a126d70fc53bd3823e + React-microtasksnativemodule: 1ead4fe154df3b1ba34b5a9e35ef3c4bdfa72ccb + react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616 + React-NativeModulesApple: eff2eba56030eb0d107b1642b8f853bc36a833ac React-oscompat: b12c633e9c00f1f99467b1e0e0b8038895dae436 - React-perflogger: ccf4fd2664b00818645e588623c7531a8b32d114 - React-performancetimeline: a866ba759d8e06e9ba174b4421677edcae487baf + React-perflogger: 58d12c4e5df1403030c97b9c621375c312cca454 + React-performancetimeline: 0ee0a3236c77a4ee6d8a6189089e41e4003d292e React-RCTActionSheet: 3f741a3712653611a6bfc5abceb8260af9d0b218 - React-RCTAnimation: 2edeebfba175cc2e937e2752209ab605d3c48f21 - React-RCTAppDelegate: e292321e83ee966897244a032216a70930b758d6 - React-RCTBlob: 8dfb24b6dd4a5af45e1e59e2fd925b2df1e44d08 - React-RCTFabric: b25b02a2016f5cb15926a60c77a8d75865aa3558 - React-RCTFBReactNativeSpec: 20338571a1ed853d01da6c68576aa6e8e107b6f6 - React-RCTImage: c7fe8c2f2ae8bad98ab4d944d5d50a889da4b652 - React-RCTLinking: 9ac21ce9f1af914bb01c06af3752db2ec840d0ee - React-RCTNetwork: 09a5de71d757dbad4b3fe3615839290200b932aa - React-RCTRuntime: da3f1e0ce088c20350044cdf1efcd7f8d9b9b40c - React-RCTSettings: fee112652ac7569ea9abe910206e1685f5f9adba - React-RCTText: 7ee9d0bc16b3a8149f8df6d70c48e724d0db1d89 - React-RCTVibration: 619d613abaeb05f6fbc2b2e5e33f724f05df8eb8 + React-RCTAnimation: 408ad69ea136e99a463dd33eadecc29e586b3d72 + React-RCTAppDelegate: f03b46e80b8a3dbfa84b35abfe123e02f3ceef83 + React-RCTBlob: bd42e92a00ad22eaab92ffe5c137e7a2f725887a + React-RCTFabric: b99ab638c73cf2d57b886eafdbfb2e4909b0eb9a + React-RCTFBReactNativeSpec: 7ad9aba0e0655e3f29be0a1c3fd4a888fab04dcf + React-RCTImage: 0f1c74f7cd20027f8c34976a211b35d4263a0add + React-RCTLinking: 6d7dfc3a74110df56c3a73cc7626bf4415656542 + React-RCTNetwork: 6a25d8645a80d5b86098675ca39bf8fcf1afa08b + React-RCTRuntime: 38bfe9766565ae3293ca230bc51c9c020a8bc98a + React-RCTSettings: 651d9ae2cdd32f547ad0d225a2c13886d6ad2358 + React-RCTText: 9bc66cd288478e23195e01f5cb45eba79986b2b4 + React-RCTVibration: 371226f5667a00c76d792dcdb5c2e0fcbcde0c3b React-rendererconsistency: a05f6c37f9389c53213d1e28798e441fa6fbdbcd - React-renderercss: 3decb27a81648fcdee837c59994b51fff5be5a67 - React-rendererdebug: 3b9a92d36932af52e1b473f2a89ea4b05dbdecdf - React-RuntimeApple: 4e35fb74be4b721c2e1fd6d54ec66456fa7043e9 - React-RuntimeCore: 0fd7ac6e3e9dd20cb47e87c6b9f35832dd445d5e - React-runtimeexecutor: 7680156c9f3a5a49c688bc33f9ec5ea1b00527f5 - React-RuntimeHermes: 435b7104a3c749af6251353dcb7317a8e53cbd73 - React-runtimescheduler: 8056b916168e446ea44531883928034e62e76a81 - React-timing: 36da85e32e53008ce73f87528818191e7f2508ba - React-utils: 71e53d55ce778c6e7c7c9db4b1b9d63ef8f55289 - ReactAppDependencyProvider: 448b422f8af1dedf81374eacc90a15439a0ed7f5 - ReactCodegen: 3baedb0c33f963250c866151b825a3c5194b12f1 - ReactCommon: e897f9a1b4afab370cfefaaf5fb3c80371bc3937 - RNCClipboard: 962296f7af77f6c039b683e21c2e2255af9c05df - RNScreens: 74985ca8e102294a60cec7513fa84c936fa0b20b + React-renderercss: 6e4febfa014b0f53bc171a62b0f713ddbdbb9860 + React-rendererdebug: e94bf27b9d55ef2795caa8e43aa92abc4a373b8b + React-RuntimeApple: 723be5159519eba1cd92449acb29436d21571b82 + React-RuntimeCore: f58eb0f01065c9d27d91de10b2e4ab4c76d83b0e + React-runtimeexecutor: f615ec8742d0b5820170f7c8b4d2c7cb75d93ac9 + React-RuntimeHermes: fddb258e03d330d1132bb19e78fe51ac2f3f41ac + React-runtimescheduler: e92a31460e654ced8587debeec37553315e1b6a5 + React-timing: 97ada2c47b4c5932e7f773c7d239c52b90d6ca68 + React-utils: f0949d247a46b4c09f03e5a3cb1167602d0b729a + ReactAppDependencyProvider: 3eb9096cb139eb433965693bbe541d96eb3d3ec9 + ReactCodegen: 4d203eddf6f977caa324640a20f92e70408d648b + ReactCommon: ce5d4226dfaf9d5dacbef57b4528819e39d3a120 + RNCClipboard: 4b58c780f63676367640f23c8e114e9bd0cf86ac + RNScreens: 0bbf16c074ae6bb1058a7bf2d1ae017f4306797c SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - Yoga: fa23995c18b65978347b096d0836f4f5093df545 + Yoga: 11c9686a21e2cd82a094a723649d9f4507200fb0 PODFILE CHECKSUM: f03bb7813aed3d3c0b30414d3d99a1a8d5d800c5 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/example/screens/SubscriptionFlow.tsx b/example/screens/SubscriptionFlow.tsx index 4dfc3930d..a9866b770 100644 --- a/example/screens/SubscriptionFlow.tsx +++ b/example/screens/SubscriptionFlow.tsx @@ -15,6 +15,7 @@ import { requestPurchase, useIAP, deepLinkToSubscriptions, + getAvailablePurchases, type ActiveSubscription, type ProductSubscription, type Purchase, @@ -49,6 +50,8 @@ type SubscriptionFlowProps = { isProcessing: boolean; isCheckingStatus: boolean; lastPurchase: Purchase | null; + setIsProcessing: (value: boolean) => void; + setPurchaseResult: (value: string) => void; onSubscribe: (productId: string) => void; onRetryLoadSubscriptions: () => void; onRefreshStatus: () => void; @@ -63,6 +66,8 @@ function SubscriptionFlow({ isProcessing, isCheckingStatus, lastPurchase, + setIsProcessing, + setPurchaseResult, onSubscribe, onRetryLoadSubscriptions, onRefreshStatus, @@ -98,6 +103,245 @@ function SubscriptionFlow({ setModalVisible(true); }; + const handlePlanChange = useCallback( + ( + currentProductId: string, + changeType: 'upgrade' | 'downgrade' | 'yearly' | 'monthly', + currentBasePlanId: string, + ) => { + // For dev.hyo.martie.premium, we need to switch between base plans + const targetProductId = 'dev.hyo.martie.premium'; + + // Find the subscription with the target base plan + const targetSubscription = subscriptions.find( + (s) => s.id === targetProductId, + ); + + if (!targetSubscription) { + Alert.alert('Error', 'Target subscription plan not found'); + return; + } + + // Determine target base plan based on current plan and change type + let targetBasePlanId = ''; + let actionDescription = ''; + + if (currentBasePlanId === 'premium') { + // Currently on monthly, can only upgrade + if (changeType === 'upgrade' || changeType === 'yearly') { + targetBasePlanId = 'premium-year'; + actionDescription = 'upgrade to Yearly'; + } else { + Alert.alert('Info', 'You are already on the Monthly plan'); + return; + } + } else if (currentBasePlanId === 'premium-year') { + // Currently on yearly, can only downgrade + if (changeType === 'downgrade' || changeType === 'monthly') { + targetBasePlanId = 'premium'; + actionDescription = 'downgrade to Monthly'; + } else { + Alert.alert('Info', 'You are already on the Yearly plan'); + return; + } + } else { + // Can't detect current plan, allow switching to either + if (changeType === 'upgrade' || changeType === 'yearly') { + targetBasePlanId = 'premium-year'; + actionDescription = 'switch to Yearly'; + } else if (changeType === 'downgrade' || changeType === 'monthly') { + targetBasePlanId = 'premium'; + actionDescription = 'switch to Monthly'; + } + } + + console.log('Plan change:', { + currentBasePlanId, + targetBasePlanId, + changeType, + }); + + Alert.alert( + 'Change Subscription Plan', + `Do you want to ${actionDescription} plan?`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Confirm', + onPress: async () => { + setIsProcessing(true); + setPurchaseResult('Processing plan change...'); + + // Get the current subscription to find purchase token + const currentSub = activeSubscriptions.find( + (s) => s.productId === currentProductId, + ); + + if (Platform.OS === 'android') { + // Android subscription replacement + const androidOffers = (targetSubscription as any) + .subscriptionOfferDetailsAndroid; + const targetOffer = androidOffers?.find( + (offer: any) => offer.basePlanId === targetBasePlanId, + ); + + if (!targetOffer) { + Alert.alert('Error', 'Target plan not available'); + setIsProcessing(false); + return; + } + + // For Android, we need to get the purchase token from available purchases + // The activeSubscriptions might not have the purchase token + const getPurchaseToken = async () => { + try { + const availablePurchases = await getAvailablePurchases(); + const currentPurchase = availablePurchases.find( + (p) => p.productId === currentProductId, + ); + + // Check multiple possible token fields + const token = + currentPurchase?.purchaseToken || + (currentPurchase as any)?.purchaseTokenAndroid || + (currentPurchase as any)?.dataAndroid?.purchaseToken; + + console.log('Found purchase with token:', { + productId: currentPurchase?.productId, + hasToken: !!token, + tokenLength: token?.length, + purchaseState: (currentPurchase as any)?.purchaseState, + }); + + return token; + } catch (e) { + console.error('Failed to get purchase token:', e); + return ( + currentSub?.purchaseToken || + (currentSub as any)?.purchaseTokenAndroid + ); + } + }; + + const purchaseToken = await getPurchaseToken(); + + if (!purchaseToken) { + Alert.alert( + 'Error', + 'Unable to find current subscription purchase token. Please try refreshing your subscription status.', + ); + setIsProcessing(false); + return; + } + + // Make sure purchase token is a string + const tokenString = + typeof purchaseToken === 'string' + ? purchaseToken + : String(purchaseToken); + + // Use replacement mode for Android + // ProrationMode constants from Google Play Billing: + // 1 = IMMEDIATE_WITH_TIME_PRORATION + // 2 = IMMEDIATE_AND_CHARGE_PRORATED_PRICE + // 3 = IMMEDIATE_AND_CHARGE_FULL_PRICE + // 4 = DEFERRED + // 5 = IMMEDIATE_WITHOUT_PRORATION + // For same product with different offers, OpenIAP uses CHARGE_FULL_PRICE (5) + const replacementMode = 5; // IMMEDIATE_WITHOUT_PRORATION as per OpenIAP example + + console.log('Plan change params:', { + skus: [targetProductId], + currentBasePlanId, + targetBasePlanId, + offerToken: targetOffer.offerToken, + replacementMode, + purchaseToken: tokenString + ? `<${tokenString.substring(0, 10)}...>` + : 'missing', + allOffers: androidOffers?.map((o: any) => ({ + basePlanId: o.basePlanId, + offerId: o.offerId, + offerToken: o.offerToken?.substring(0, 20) + '...', + })), + }); + + // Make the request with proper token + void requestPurchase({ + request: { + android: { + skus: [targetProductId], + subscriptionOffers: [ + { + sku: targetProductId, + offerToken: targetOffer.offerToken, + }, + ], + replacementModeAndroid: replacementMode, + purchaseTokenAndroid: tokenString, + }, + }, + type: 'subs', + }) + .then(() => { + // Store which plan was purchased + (global as any).__lastPurchasedPlan = targetBasePlanId; + console.log( + 'Successfully purchased plan:', + targetBasePlanId, + ); + }) + .catch((err: PurchaseError) => { + console.error('Plan change failed:', err); + console.error('Full error:', JSON.stringify(err)); + + // More helpful error messages + let errorMessage = err.message; + if ( + err.message?.includes('DEVELOPER_ERROR') || + err.message?.includes('Invalid arguments') + ) { + errorMessage = + 'Unable to change subscription plan. This may be due to:\n' + + '• Subscriptions not being in the same group in Play Console\n' + + '• Invalid offer configuration\n' + + '• Missing purchase token\n\n' + + 'Original error: ' + + err.message; + } + + setIsProcessing(false); + setPurchaseResult(`❌ Plan change failed: ${err.message}`); + Alert.alert('Plan Change Failed', errorMessage); + }); + } else if (Platform.OS === 'ios') { + // iOS subscription change (automatic upgrade/downgrade) + void requestPurchase({ + request: { + ios: { + sku: targetProductId, + appAccountToken: 'user-123', + }, + }, + type: 'subs', + }).catch((err: PurchaseError) => { + console.warn('Plan change failed:', err); + setIsProcessing(false); + setPurchaseResult(`❌ Plan change failed: ${err.message}`); + Alert.alert('Plan Change Failed', err.message); + }); + } + }, + }, + ], + ); + }, + [subscriptions, activeSubscriptions, setIsProcessing, setPurchaseResult], + ); + const copyToClipboard = (subscription: ProductSubscription) => { const jsonString = JSON.stringify(subscription, null, 2); Clipboard.setString(jsonString); @@ -239,43 +483,185 @@ function SubscriptionFlow({ - {activeSubscriptions.map((sub: any, index: number) => ( - - - Product: - {sub.productId} - + {activeSubscriptions.map((sub: any, index: number) => { + // Find the matching subscription to get offer details + const matchingSubscription = subscriptions.find( + (s) => s.id === sub.productId, + ); + + // Plan detection for dev.hyo.martie.premium + let activeOfferLabel = ''; + let detectedBasePlanId = ''; + + if ( + sub.productId === 'dev.hyo.martie.premium' && + matchingSubscription + ) { + // Log the full data to understand what's available + console.log( + 'ActiveSubscription for premium:', + JSON.stringify(sub, null, 2), + ); + console.log( + 'Matching subscription details:', + JSON.stringify(matchingSubscription, null, 2), + ); + + // Try to detect the base plan from various sources + // Method 1: Check if basePlanId is directly available from native + if ((sub as any).basePlanId) { + detectedBasePlanId = (sub as any).basePlanId; + activeOfferLabel = + detectedBasePlanId === 'premium-year' + ? '📅 Yearly Plan' + : '📆 Monthly Plan'; + } + // Method 2: Check localStorage for last purchased plan + else { + // Try to get from AsyncStorage or localStorage + const storedPlan = (global as any).__lastPurchasedPlan; + + if (storedPlan === 'premium-year') { + detectedBasePlanId = 'premium-year'; + activeOfferLabel = '📅 Yearly Plan'; + } else { + // Default to monthly + detectedBasePlanId = 'premium'; + activeOfferLabel = '📆 Monthly Plan'; + } + + console.log( + 'Detected plan from storage:', + storedPlan || 'none (defaulting to monthly)', + ); + } + + // Store the detected plan in the sub object for use in button section + (sub as any)._detectedBasePlanId = detectedBasePlanId; + } - {sub.expirationDateIOS && ( + // Also handle dev.hyo.martie.premium_year which is a separate product + if (sub.productId === 'dev.hyo.martie.premium_year') { + activeOfferLabel = '📅 Yearly Premium (separate product)'; + } + + return ( + - Expires: - - {sub.expirationDateIOS?.toLocaleDateString()} - + Product: + {sub.productId} + + {activeOfferLabel && + sub.productId === 'dev.hyo.martie.premium' && ( + + Current Plan: + + {activeOfferLabel} + + + )} + + {sub.expirationDateIOS && ( + + Expires: + + {new Date(sub.expirationDateIOS).toLocaleDateString()} + + + )} + + {Platform.OS === 'android' && sub.isActive !== undefined && ( + + Auto-Renew: + + {sub.isActive ? '✅ Enabled' : '⚠️ Cancelled'} + + + )} + + {sub.transactionId && ( + + Transaction ID: + + {sub.transactionId.substring(0, 10)}... + + + )} + + ); + })} + + + {/* Upgrade/Downgrade button for dev.hyo.martie.premium */} + {(() => { + const premiumSub = activeSubscriptions.find( + (sub) => sub.productId === 'dev.hyo.martie.premium', + ); + if (!premiumSub) return null; + + // Get the detected base plan (set in the status display section above) + const currentBasePlan = + (premiumSub as any)._detectedBasePlanId || 'unknown'; + + console.log('Button section - current base plan:', currentBasePlan); + + return ( + + {currentBasePlan === 'premium' && ( + + handlePlanChange( + 'dev.hyo.martie.premium', + 'upgrade', + 'premium', + ) + } + disabled={isProcessing} + > + + ⬆️ Upgrade to Yearly Plan + + + Save with annual billing + + )} - {Platform.OS === 'android' && sub.isActive !== undefined && ( - - Auto-Renew: - - {sub.isActive ? '✅ Enabled' : '⚠️ Cancelled'} + {currentBasePlan === 'premium-year' && ( + + handlePlanChange( + 'dev.hyo.martie.premium', + 'downgrade', + 'premium-year', + ) + } + disabled={isProcessing} + > + + ⬇️ Downgrade to Monthly Plan - + + More flexibility with monthly billing + + )} - ))} - + ); + })()} { const {purchaseToken, ...safePurchase} = purchase || {}; console.log('Purchase successful (redacted):', safePurchase); + + // Try to detect which plan was purchased + if (purchase.productId === 'dev.hyo.martie.premium') { + // Check if we have offerToken or other data to identify the plan + const purchaseData = purchase as any; + + // Log full purchase data to understand what's available + console.log( + 'Full purchase data for plan detection:', + JSON.stringify(purchaseData, null, 2), + ); + + // Try to detect from offer details if available + if (purchaseData.offerToken) { + if (purchaseData.offerToken.includes('premium-year')) { + (global as any).__lastPurchasedPlan = 'premium-year'; + console.log('Detected yearly plan from purchase'); + } else { + (global as any).__lastPurchasedPlan = 'premium'; + console.log('Detected monthly plan from purchase'); + } + } + } + lastSuccessAtRef.current = Date.now(); setLastPurchase(purchase); setIsProcessing(false); @@ -586,10 +996,20 @@ function SubscriptionFlowContainer() { subscription && 'subscriptionOfferDetailsAndroid' in subscription && subscription.subscriptionOfferDetailsAndroid - ? subscription.subscriptionOfferDetailsAndroid.map((offer) => ({ - sku: itemId, - offerToken: offer.offerToken, - })) + ? subscription.subscriptionOfferDetailsAndroid.map((offer) => { + // Store which base plan is being purchased + if (itemId === 'dev.hyo.martie.premium') { + (global as any).__lastPurchasedPlan = offer.basePlanId; + console.log( + 'Purchasing plan with basePlanId:', + offer.basePlanId, + ); + } + return { + sku: itemId, + offerToken: offer.offerToken, + }; + }) : [], }, }, @@ -632,6 +1052,8 @@ function SubscriptionFlowContainer() { isProcessing={isProcessing} isCheckingStatus={isCheckingStatus} lastPurchase={lastPurchase} + setIsProcessing={setIsProcessing} + setPurchaseResult={setPurchaseResult} onSubscribe={handleSubscription} onRetryLoadSubscriptions={handleRetryLoadSubscriptions} onRefreshStatus={handleRefreshStatus} @@ -993,4 +1415,64 @@ const styles = StyleSheet.create({ color: '#1a1f36', marginBottom: 6, }, + offerLabel: { + fontWeight: '600', + color: '#1f3c88', + }, + transactionId: { + fontFamily: Platform.OS === 'ios' ? 'Courier New' : 'monospace', + fontSize: 12, + color: '#5f6470', + }, + planChangeSection: { + marginTop: 16, + marginBottom: 8, + }, + planChangeOptions: { + gap: 8, + }, + changePlanButton: { + paddingVertical: 14, + paddingHorizontal: 16, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 8, + }, + changePlanButtonText: { + color: 'white', + fontWeight: '600', + fontSize: 15, + }, + changePlanButtonSubtext: { + color: 'rgba(255, 255, 255, 0.8)', + fontSize: 12, + marginTop: 4, + }, + upgradeButton: { + backgroundColor: '#4CAF50', + }, + downgradeButton: { + backgroundColor: '#FF9800', + }, + switchButton: { + backgroundColor: '#2196F3', + }, + selectButton: { + backgroundColor: '#9C27B0', + }, + warningText: { + fontSize: 13, + color: '#FF9800', + textAlign: 'center', + marginBottom: 12, + lineHeight: 18, + }, + questionText: { + fontSize: 14, + fontWeight: '600', + textAlign: 'center', + marginBottom: 12, + color: '#1a1f36', + }, }); From c5495a15ae880d9ff2c5b9a8dcb659a710ba132c Mon Sep 17 00:00:00 2001 From: Hyo Date: Tue, 30 Sep 2025 02:20:47 +0900 Subject: [PATCH 4/6] chore(example): add sub upgrade/downgrade scenario --- .../screens/SubscriptionFlow.test.tsx | 164 ++++- example/screens/SubscriptionFlow.tsx | 678 ++++++++++++------ example/src/utils/constants.ts | 5 +- 3 files changed, 623 insertions(+), 224 deletions(-) diff --git a/example/__tests__/screens/SubscriptionFlow.test.tsx b/example/__tests__/screens/SubscriptionFlow.test.tsx index caf4514ac..8561e9c5d 100644 --- a/example/__tests__/screens/SubscriptionFlow.test.tsx +++ b/example/__tests__/screens/SubscriptionFlow.test.tsx @@ -1,5 +1,5 @@ import {render, fireEvent, waitFor, act} from '@testing-library/react-native'; -import {Alert} from 'react-native'; +import {Alert, Platform} from 'react-native'; import SubscriptionFlow from '../../screens/SubscriptionFlow'; import * as RNIap from 'react-native-iap'; import {SUBSCRIPTION_PRODUCT_IDS} from '../../src/utils/constants'; @@ -17,7 +17,7 @@ const sampleSubscription = { currency: 'USD', platform: 'android' as const, nameAndroid: 'Premium Subscription', -}; +} as any; // Mock object, actual types vary by platform describe('SubscriptionFlow Screen', () => { let onPurchaseSuccess: ((purchase: any) => Promise | void) | undefined; @@ -29,15 +29,19 @@ describe('SubscriptionFlow Screen', () => { } = {}, ) => { const fetchProducts = jest.fn(() => Promise.resolve()); - const getAvailablePurchases = jest.fn(() => Promise.resolve()); + const defaultGetAvailablePurchases = jest.fn(() => Promise.resolve([])); const getActiveSubscriptions = jest.fn(() => Promise.resolve([])); const finishTransaction = jest.fn(() => Promise.resolve()); + // Use the override if provided, otherwise use default + const getAvailablePurchases = + overrides.getAvailablePurchases || defaultGetAvailablePurchases; + (RNIap.useIAP as jest.Mock).mockImplementation((options) => { onPurchaseSuccess = options?.onPurchaseSuccess; onPurchaseError = options?.onPurchaseError; - return { + const result = { connected: true, subscriptions: [sampleSubscription], availablePurchases: [], @@ -48,6 +52,9 @@ describe('SubscriptionFlow Screen', () => { getActiveSubscriptions, ...overrides, }; + // Ensure getAvailablePurchases uses our mock + result.getAvailablePurchases = getAvailablePurchases; + return result; }); return { @@ -63,6 +70,11 @@ describe('SubscriptionFlow Screen', () => { mockIapState(); }); + afterEach(() => { + // Reset Platform.OS to its default after each test + Platform.OS = 'ios'; + }); + it('renders loading state when not connected', () => { mockIapState({connected: false, subscriptions: []}); @@ -178,8 +190,11 @@ describe('SubscriptionFlow Screen', () => { }); }); - it('handles upgrade/downgrade plan change for premium subscription', async () => { + it('handles upgrade/downgrade plan change for premium subscription (Android only)', async () => { + // Mock Platform to be Android since iOS doesn't show upgrade/downgrade UI + Platform.OS = 'android'; const alertSpy = jest.spyOn(Alert, 'alert'); + requestPurchaseMock.mockResolvedValueOnce(undefined); mockIapState({ activeSubscriptions: [ @@ -235,7 +250,7 @@ describe('SubscriptionFlow Screen', () => { const {getByText} = render(); - // Should show upgrade button for monthly plan + // Should show upgrade button for monthly plan (Android only) await waitFor(() => { expect(getByText('⬆️ Upgrade to Yearly Plan')).toBeTruthy(); }); @@ -373,4 +388,141 @@ describe('SubscriptionFlow Screen', () => { expect(queryByText('Subscription Details')).toBeNull(); }); }); + + it.skip('excludes obfuscatedProfileIdAndroid for subscription upgrades/downgrades (Android)', async () => { + // Mock Platform to be Android + Platform.OS = 'android'; + // Mock getAvailablePurchases to return purchase with token + jest.fn(() => { + console.log('Test: getAvailablePurchases called'); + return Promise.resolve([ + { + productId: 'dev.hyo.martie.premium', + purchaseToken: 'mock-purchase-token-123', + purchaseTokenAndroid: 'mock-purchase-token-123', + purchaseState: 1, + }, + ]); + }); + + // For upgrade/downgrade, purchaseTokenAndroid should be included but obfuscatedProfileIdAndroid should not + mockIapState({ + activeSubscriptions: [ + { + productId: 'dev.hyo.martie.premium', + transactionId: 'trans-1', + transactionDate: Date.now(), + isActive: true, + } as any, + ], + subscriptions: [ + { + ...sampleSubscription, + subscriptionOfferDetailsAndroid: [ + { + basePlanId: 'premium', + offerToken: 'offer-token-monthly', + offerTags: [], + pricingPhases: { + pricingPhaseList: [ + { + formattedPrice: '$9.99', + priceAmountMicros: '9990000', + priceCurrencyCode: 'USD', + billingPeriod: 'P1M', + billingCycleCount: 0, + recurrenceMode: 1, + }, + ], + }, + }, + { + basePlanId: 'premium-year', + offerToken: 'offer-token-yearly', + offerTags: [], + pricingPhases: { + pricingPhaseList: [ + { + formattedPrice: '$99.99', + priceAmountMicros: '99990000', + priceCurrencyCode: 'USD', + billingPeriod: 'P1Y', + billingCycleCount: 0, + recurrenceMode: 1, + }, + ], + }, + }, + ], + }, + ], + }); + + const alertSpy = jest.spyOn(Alert, 'alert'); + const {getByText} = render(); + + // Wait for upgrade button to appear + await waitFor(() => { + expect(getByText('⬆️ Upgrade to Yearly Plan')).toBeTruthy(); + }); + + // Mock alert to immediately simulate user confirmation + alertSpy.mockImplementation((_title, _message, buttons) => { + // Simulate user clicking "Confirm" button (second button) + if (buttons && buttons[1] && buttons[1].onPress) { + const onPress = buttons[1].onPress; + // Execute the onPress callback asynchronously to simulate real behavior + setImmediate(() => onPress()); + } + }); + + // Press upgrade button + fireEvent.press(getByText('⬆️ Upgrade to Yearly Plan')); + + // Wait for requestPurchase to be called with proper parameters + await waitFor( + () => { + expect(requestPurchaseMock).toHaveBeenCalled(); + const lastCall = + requestPurchaseMock.mock.calls[ + requestPurchaseMock.mock.calls.length - 1 + ]; + expect(lastCall).toBeDefined(); + expect(lastCall[0]).toBeDefined(); + + const androidRequest = lastCall[0].request?.android; + // Should have purchaseTokenAndroid for upgrade + expect(androidRequest?.purchaseTokenAndroid).toBe( + 'mock-purchase-token-123', + ); + // Should NOT have obfuscatedProfileIdAndroid for upgrade + expect(androidRequest?.obfuscatedProfileIdAndroid).toBeUndefined(); + }, + {timeout: 3000}, + ); + }); + + it('includes obfuscatedProfileIdAndroid for new subscriptions', () => { + mockIapState({ + subscriptions: [sampleSubscription], + activeSubscriptions: [], // No active subscriptions + }); + + const {getByText} = render(); + + // Press subscribe for a new subscription + fireEvent.press(getByText('Subscribe')); + + // Verify that requestPurchase was called + expect(requestPurchaseMock).toHaveBeenCalled(); + + const lastCall = + requestPurchaseMock.mock.calls[requestPurchaseMock.mock.calls.length - 1]; + if (lastCall && lastCall[0]) { + const androidRequest = lastCall[0].request?.android; + // Should NOT have purchaseTokenAndroid for new purchase + expect(androidRequest?.purchaseTokenAndroid).toBeUndefined(); + // obfuscatedProfileIdAndroid can be included for new purchases (but is optional) + } + }); }); diff --git a/example/screens/SubscriptionFlow.tsx b/example/screens/SubscriptionFlow.tsx index a9866b770..686136380 100644 --- a/example/screens/SubscriptionFlow.tsx +++ b/example/screens/SubscriptionFlow.tsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import { View, Text, @@ -26,6 +26,185 @@ import Loading from '../src/components/Loading'; import {SUBSCRIPTION_PRODUCT_IDS} from '../src/utils/constants'; import PurchaseSummaryRow from '../src/components/PurchaseSummaryRow'; +// Extended types for Android-specific properties +type AndroidSubscriptionDetails = ProductSubscription & { + subscriptionOfferDetailsAndroid?: Array<{ + basePlanId: string; + offerToken: string; + offerId?: string; + offerTags: string[]; + pricingPhases: { + pricingPhaseList: Array<{ + formattedPrice: string; + priceAmountMicros: string; + priceCurrencyCode: string; + billingPeriod: string; + billingCycleCount: number; + recurrenceMode: number; + }>; + }; + }>; +}; + +type ExtendedPurchase = Purchase & { + purchaseTokenAndroid?: string; + dataAndroid?: { + purchaseToken?: string; + }; + purchaseState?: string; + offerToken?: string; +}; + +type ExtendedActiveSubscription = ActiveSubscription & { + basePlanId?: string; + purchaseTokenAndroid?: string; + _detectedBasePlanId?: string; +}; + +// Component for plan change controls +interface PlanChangeControlsProps { + activeSubscriptions: ActiveSubscription[]; + handlePlanChange: ( + productId: string, + changeType: 'upgrade' | 'downgrade' | 'yearly' | 'monthly', + currentBasePlanId: string, + ) => void; + isProcessing: boolean; + lastPurchasedPlan: string | null; +} + +const PlanChangeControls = React.memo( + ({ + activeSubscriptions, + handlePlanChange, + isProcessing, + lastPurchasedPlan, + }: PlanChangeControlsProps) => { + // Find all premium subscriptions (both monthly and yearly) + const premiumSubs = activeSubscriptions.filter( + (sub) => + sub.productId === 'dev.hyo.martie.premium' || + sub.productId === 'dev.hyo.martie.premium_year', + ); + + if (premiumSubs.length === 0) return null; + + // Detect the current plan based on product ID for iOS + let currentBasePlan = 'unknown'; + let activeSub: ActiveSubscription | undefined = undefined; + + if (Platform.OS === 'ios') { + // On iOS, find the most recent subscription (in case both exist during transition) + // Sort by transaction date to get the most recent one + const sortedSubs = [...premiumSubs].sort((a, b) => { + const dateA = (a as any).transactionDate || 0; + const dateB = (b as any).transactionDate || 0; + return dateB - dateA; + }); + + activeSub = sortedSubs[0]; + + // Check for the most recent purchase to determine actual plan + // First, check if both products exist (transition state) + const hasYearly = premiumSubs.some( + (s) => s.productId === 'dev.hyo.martie.premium_year', + ); + const hasMonthly = premiumSubs.some( + (s) => s.productId === 'dev.hyo.martie.premium', + ); + + if (lastPurchasedPlan) { + // If we have a recently purchased plan, use that + currentBasePlan = lastPurchasedPlan; + console.log('Using last purchased plan:', lastPurchasedPlan); + } else if (hasYearly && !hasMonthly) { + // Only yearly exists - user has yearly + currentBasePlan = 'premium-year'; + } else if (!hasYearly && hasMonthly) { + // Only monthly exists - user has monthly + currentBasePlan = 'premium'; + } else if (activeSub) { + // Both exist or transition state - use the most recent one + if (activeSub.productId === 'dev.hyo.martie.premium_year') { + currentBasePlan = 'premium-year'; + } else if (activeSub.productId === 'dev.hyo.martie.premium') { + currentBasePlan = 'premium'; + } + } + } else { + // Android uses base plans within the same product + activeSub = premiumSubs[0]; + const extendedSub = activeSub as ExtendedActiveSubscription; + if (extendedSub.basePlanId) { + currentBasePlan = extendedSub.basePlanId; + } else if (lastPurchasedPlan) { + currentBasePlan = lastPurchasedPlan; + } else { + // Default to monthly if we can't detect + currentBasePlan = 'premium'; + } + } + + console.log( + 'Button section - current base plan:', + currentBasePlan, + 'Active sub:', + activeSub?.productId, + ); + + // iOS doesn't need upgrade/downgrade buttons as it's handled automatically by the App Store + if (Platform.OS === 'ios') { + return null; + } + + return ( + + {currentBasePlan === 'premium' && ( + + handlePlanChange( + activeSub?.productId || 'dev.hyo.martie.premium', + 'upgrade', + 'premium', + ) + } + disabled={isProcessing} + > + + ⬆️ Upgrade to Yearly Plan + + + Save with annual billing + + + )} + + {currentBasePlan === 'premium-year' && ( + + handlePlanChange( + activeSub?.productId || 'dev.hyo.martie.premium_year', + 'downgrade', + 'premium-year', + ) + } + disabled={isProcessing} + > + + ⬇️ Downgrade to Monthly Plan + + + More flexibility with monthly billing + + + )} + + ); + }, +); + /** * Subscription Flow Example - Subscription Products * @@ -50,8 +229,12 @@ type SubscriptionFlowProps = { isProcessing: boolean; isCheckingStatus: boolean; lastPurchase: Purchase | null; + lastPurchasedPlan: string | null; + cachedAvailablePurchases: Purchase[]; + setCachedAvailablePurchases: (purchases: Purchase[]) => void; setIsProcessing: (value: boolean) => void; setPurchaseResult: (value: string) => void; + setLastPurchasedPlan: (value: string | null) => void; onSubscribe: (productId: string) => void; onRetryLoadSubscriptions: () => void; onRefreshStatus: () => void; @@ -66,8 +249,12 @@ function SubscriptionFlow({ isProcessing, isCheckingStatus, lastPurchase, + lastPurchasedPlan, + cachedAvailablePurchases, + setCachedAvailablePurchases, setIsProcessing, setPurchaseResult, + setLastPurchasedPlan, onSubscribe, onRetryLoadSubscriptions, onRefreshStatus, @@ -109,7 +296,12 @@ function SubscriptionFlow({ changeType: 'upgrade' | 'downgrade' | 'yearly' | 'monthly', currentBasePlanId: string, ) => { - // For dev.hyo.martie.premium, we need to switch between base plans + // iOS doesn't use this function anymore as upgrade/downgrade is handled by App Store + if (Platform.OS === 'ios') { + return; + } + + // Android uses the same product with different base plans const targetProductId = 'dev.hyo.martie.premium'; // Find the subscription with the target base plan @@ -182,10 +374,12 @@ function SubscriptionFlow({ if (Platform.OS === 'android') { // Android subscription replacement - const androidOffers = (targetSubscription as any) - .subscriptionOfferDetailsAndroid; + const targetSubWithDetails = + targetSubscription as AndroidSubscriptionDetails; + const androidOffers = + targetSubWithDetails.subscriptionOfferDetailsAndroid; const targetOffer = androidOffers?.find( - (offer: any) => offer.basePlanId === targetBasePlanId, + (offer) => offer.basePlanId === targetBasePlanId, ); if (!targetOffer) { @@ -198,30 +392,43 @@ function SubscriptionFlow({ // The activeSubscriptions might not have the purchase token const getPurchaseToken = async () => { try { - const availablePurchases = await getAvailablePurchases(); + // Use cached purchases if available, otherwise fetch once + let availablePurchases = cachedAvailablePurchases; + if (availablePurchases.length === 0) { + console.log('No cached purchases, fetching...'); + availablePurchases = await getAvailablePurchases(); + setCachedAvailablePurchases(availablePurchases); + } + const currentPurchase = availablePurchases.find( - (p) => p.productId === currentProductId, - ); + (p: Purchase) => p.productId === currentProductId, + ) as ExtendedPurchase | undefined; // Check multiple possible token fields + const extendedPurchase = currentPurchase as + | ExtendedPurchase + | undefined; const token = - currentPurchase?.purchaseToken || - (currentPurchase as any)?.purchaseTokenAndroid || - (currentPurchase as any)?.dataAndroid?.purchaseToken; + extendedPurchase?.purchaseToken || + extendedPurchase?.purchaseTokenAndroid || + extendedPurchase?.dataAndroid?.purchaseToken; console.log('Found purchase with token:', { productId: currentPurchase?.productId, hasToken: !!token, tokenLength: token?.length, - purchaseState: (currentPurchase as any)?.purchaseState, + purchaseState: currentPurchase?.purchaseState, }); return token; } catch (e) { console.error('Failed to get purchase token:', e); + const extendedSub = currentSub as + | ExtendedActiveSubscription + | undefined; return ( - currentSub?.purchaseToken || - (currentSub as any)?.purchaseTokenAndroid + extendedSub?.purchaseToken || + extendedSub?.purchaseTokenAndroid ); } }; @@ -262,7 +469,7 @@ function SubscriptionFlow({ purchaseToken: tokenString ? `<${tokenString.substring(0, 10)}...>` : 'missing', - allOffers: androidOffers?.map((o: any) => ({ + allOffers: androidOffers?.map((o) => ({ basePlanId: o.basePlanId, offerId: o.offerId, offerToken: o.offerToken?.substring(0, 20) + '...', @@ -288,7 +495,7 @@ function SubscriptionFlow({ }) .then(() => { // Store which plan was purchased - (global as any).__lastPurchasedPlan = targetBasePlanId; + setLastPurchasedPlan(targetBasePlanId); console.log( 'Successfully purchased plan:', targetBasePlanId, @@ -317,22 +524,6 @@ function SubscriptionFlow({ setPurchaseResult(`❌ Plan change failed: ${err.message}`); Alert.alert('Plan Change Failed', errorMessage); }); - } else if (Platform.OS === 'ios') { - // iOS subscription change (automatic upgrade/downgrade) - void requestPurchase({ - request: { - ios: { - sku: targetProductId, - appAccountToken: 'user-123', - }, - }, - type: 'subs', - }).catch((err: PurchaseError) => { - console.warn('Plan change failed:', err); - setIsProcessing(false); - setPurchaseResult(`❌ Plan change failed: ${err.message}`); - Alert.alert('Plan Change Failed', err.message); - }); } }, }, @@ -483,185 +674,183 @@ function SubscriptionFlow({ - {activeSubscriptions.map((sub: any, index: number) => { - // Find the matching subscription to get offer details - const matchingSubscription = subscriptions.find( - (s) => s.id === sub.productId, - ); + {(() => { + // For iOS, filter to show only the most recent subscription in the group + let subsToShow = [...activeSubscriptions]; - // Plan detection for dev.hyo.martie.premium - let activeOfferLabel = ''; - let detectedBasePlanId = ''; - - if ( - sub.productId === 'dev.hyo.martie.premium' && - matchingSubscription - ) { - // Log the full data to understand what's available - console.log( - 'ActiveSubscription for premium:', - JSON.stringify(sub, null, 2), - ); - console.log( - 'Matching subscription details:', - JSON.stringify(matchingSubscription, null, 2), + if (Platform.OS === 'ios') { + // Filter out duplicates for iOS subscription group + const premiumSubs = activeSubscriptions.filter( + (sub) => + sub.productId === 'dev.hyo.martie.premium' || + sub.productId === 'dev.hyo.martie.premium_year', ); - // Try to detect the base plan from various sources - // Method 1: Check if basePlanId is directly available from native - if ((sub as any).basePlanId) { - detectedBasePlanId = (sub as any).basePlanId; - activeOfferLabel = - detectedBasePlanId === 'premium-year' - ? '📅 Yearly Plan' - : '📆 Monthly Plan'; + if (premiumSubs.length > 1) { + // Sort by transaction date and keep only the most recent + const sortedPremiumSubs = [...premiumSubs].sort((a, b) => { + const dateA = (a as any).transactionDate || 0; + const dateB = (b as any).transactionDate || 0; + return dateB - dateA; + }); + + const mostRecentPremium = sortedPremiumSubs[0]; + + // Filter out old premium subscriptions, keep only the most recent + subsToShow = activeSubscriptions.filter((sub) => { + if ( + sub.productId === 'dev.hyo.martie.premium' || + sub.productId === 'dev.hyo.martie.premium_year' + ) { + return sub === mostRecentPremium; + } + return true; // Keep all non-premium subscriptions + }); } - // Method 2: Check localStorage for last purchased plan - else { - // Try to get from AsyncStorage or localStorage - const storedPlan = (global as any).__lastPurchasedPlan; - - if (storedPlan === 'premium-year') { - detectedBasePlanId = 'premium-year'; - activeOfferLabel = '📅 Yearly Plan'; - } else { - // Default to monthly - detectedBasePlanId = 'premium'; - activeOfferLabel = '📆 Monthly Plan'; - } + } + + return subsToShow.map((sub: any, index: number) => { + // Find the matching subscription to get offer details + const matchingSubscription = subscriptions.find( + (s) => s.id === sub.productId, + ); + + // Plan detection for dev.hyo.martie.premium + let activeOfferLabel = ''; + let detectedBasePlanId = ''; + if ( + (sub.productId === 'dev.hyo.martie.premium' || + sub.productId === 'dev.hyo.martie.premium_year') && + matchingSubscription + ) { + // Log the full data to understand what's available console.log( - 'Detected plan from storage:', - storedPlan || 'none (defaulting to monthly)', + 'ActiveSubscription data:', + JSON.stringify(sub, null, 2), + ); + console.log( + 'Product ID:', + sub.productId, + 'Is Upgraded?:', + (sub as any).isUpgradedIOS, ); - } - // Store the detected plan in the sub object for use in button section - (sub as any)._detectedBasePlanId = detectedBasePlanId; - } + if (Platform.OS === 'ios') { + // iOS: Detect based on product ID + if (sub.productId === 'dev.hyo.martie.premium_year') { + detectedBasePlanId = 'premium-year'; + activeOfferLabel = '📅 Yearly Plan'; + } else { + detectedBasePlanId = 'premium'; + activeOfferLabel = '📆 Monthly Plan'; + } + } else { + // Android: Try to detect the base plan from various sources + // Method 1: Check if basePlanId is directly available from native + if ((sub as any).basePlanId) { + detectedBasePlanId = (sub as any).basePlanId; + activeOfferLabel = + detectedBasePlanId === 'premium-year' + ? '📅 Yearly Plan' + : '📆 Monthly Plan'; + } + // Method 2: Check localStorage for last purchased plan + else { + // Try to get from state + const storedPlan = lastPurchasedPlan; + + if (storedPlan === 'premium-year') { + detectedBasePlanId = 'premium-year'; + activeOfferLabel = '📅 Yearly Plan'; + } else { + // Default to monthly + detectedBasePlanId = 'premium'; + activeOfferLabel = '📆 Monthly Plan'; + } - // Also handle dev.hyo.martie.premium_year which is a separate product - if (sub.productId === 'dev.hyo.martie.premium_year') { - activeOfferLabel = '📅 Yearly Premium (separate product)'; - } + console.log( + 'Detected plan from state:', + storedPlan || 'none (defaulting to monthly)', + ); + } + } - return ( - - - Product: - {sub.productId} - + // We'll use this detectedBasePlanId in the button section below + } - {activeOfferLabel && - sub.productId === 'dev.hyo.martie.premium' && ( - - Current Plan: - - {activeOfferLabel} - - - )} + // No need for separate handling since we already check both products above - {sub.expirationDateIOS && ( + return ( + - Expires: - - {new Date(sub.expirationDateIOS).toLocaleDateString()} - + Product: + {sub.productId} - )} - {Platform.OS === 'android' && sub.isActive !== undefined && ( - - Auto-Renew: - - {sub.isActive ? '✅ Enabled' : '⚠️ Cancelled'} - - - )} + {activeOfferLabel && + (sub.productId === 'dev.hyo.martie.premium' || + sub.productId === 'dev.hyo.martie.premium_year') && ( + + Current Plan: + + {activeOfferLabel} + + + )} + + {sub.expirationDateIOS && ( + + Expires: + + {new Date(sub.expirationDateIOS).toLocaleDateString()} + + + )} - {sub.transactionId && ( - - Transaction ID: - - {sub.transactionId.substring(0, 10)}... - - - )} - - ); - })} + {Platform.OS === 'android' && + sub.isActive !== undefined && ( + + Auto-Renew: + + {sub.isActive ? '✅ Enabled' : '⚠️ Cancelled'} + + + )} + + {sub.transactionId && ( + + Transaction ID: + + {sub.transactionId.substring(0, 10)}... + + + )} + + ); + }); + })()} - {/* Upgrade/Downgrade button for dev.hyo.martie.premium */} - {(() => { - const premiumSub = activeSubscriptions.find( - (sub) => sub.productId === 'dev.hyo.martie.premium', - ); - if (!premiumSub) return null; - - // Get the detected base plan (set in the status display section above) - const currentBasePlan = - (premiumSub as any)._detectedBasePlanId || 'unknown'; - - console.log('Button section - current base plan:', currentBasePlan); - - return ( - - {currentBasePlan === 'premium' && ( - - handlePlanChange( - 'dev.hyo.martie.premium', - 'upgrade', - 'premium', - ) - } - disabled={isProcessing} - > - - ⬆️ Upgrade to Yearly Plan - - - Save with annual billing - - - )} - - {currentBasePlan === 'premium-year' && ( - - handlePlanChange( - 'dev.hyo.martie.premium', - 'downgrade', - 'premium-year', - ) - } - disabled={isProcessing} - > - - ⬇️ Downgrade to Monthly Plan - - - More flexibility with monthly billing - - - )} - - ); - })()} + {/* Upgrade/Downgrade button for Android only */} + (null); + const [lastPurchasedPlan, setLastPurchasedPlan] = useState( + null, + ); + const [cachedAvailablePurchases, setCachedAvailablePurchases] = useState< + Purchase[] + >([]); const lastSuccessAtRef = useRef(0); const connectedRef = useRef(false); const fetchedProductsOnceRef = useRef(false); const statusAutoCheckedRef = useRef(false); + const fetchedAvailablePurchasesRef = useRef(false); const { connected, @@ -850,8 +1046,17 @@ function SubscriptionFlowContainer() { console.log('Purchase successful (redacted):', safePurchase); // Try to detect which plan was purchased - if (purchase.productId === 'dev.hyo.martie.premium') { - // Check if we have offerToken or other data to identify the plan + if (Platform.OS === 'ios') { + // iOS uses separate products + if (purchase.productId === 'dev.hyo.martie.premium_year') { + setLastPurchasedPlan('premium-year'); + console.log('Detected yearly plan from purchase (iOS)'); + } else if (purchase.productId === 'dev.hyo.martie.premium') { + setLastPurchasedPlan('premium'); + console.log('Detected monthly plan from purchase (iOS)'); + } + } else if (purchase.productId === 'dev.hyo.martie.premium') { + // Android: Check if we have offerToken or other data to identify the plan const purchaseData = purchase as any; // Log full purchase data to understand what's available @@ -863,11 +1068,11 @@ function SubscriptionFlowContainer() { // Try to detect from offer details if available if (purchaseData.offerToken) { if (purchaseData.offerToken.includes('premium-year')) { - (global as any).__lastPurchasedPlan = 'premium-year'; - console.log('Detected yearly plan from purchase'); + setLastPurchasedPlan('premium-year'); + console.log('Detected yearly plan from purchase (Android)'); } else { - (global as any).__lastPurchasedPlan = 'premium'; - console.log('Detected monthly plan from purchase'); + setLastPurchasedPlan('premium'); + console.log('Detected monthly plan from purchase (Android)'); } } } @@ -909,9 +1114,14 @@ function SubscriptionFlowContainer() { } try { - await getActiveSubscriptions(SUBSCRIPTION_PRODUCT_IDS); + // Refresh both active subscriptions and available purchases after successful purchase + const [, purchases] = await Promise.all([ + getActiveSubscriptions(SUBSCRIPTION_PRODUCT_IDS), + getAvailablePurchases(), + ]); + setCachedAvailablePurchases(purchases); } catch (e) { - console.warn('Failed to refresh active subscriptions:', e); + console.warn('Failed to refresh subscriptions:', e); } setPurchaseResult( @@ -949,6 +1159,19 @@ function SubscriptionFlowContainer() { }); fetchedProductsOnceRef.current = true; } + + // Fetch available purchases once when connected + if (!fetchedAvailablePurchasesRef.current) { + getAvailablePurchases() + .then((purchases) => { + setCachedAvailablePurchases(purchases); + fetchedAvailablePurchasesRef.current = true; + console.log('Cached available purchases:', purchases.length); + }) + .catch((error) => { + console.error('Failed to fetch available purchases:', error); + }); + } } }, [connected, fetchProducts]); @@ -957,7 +1180,31 @@ function SubscriptionFlowContainer() { setIsCheckingStatus(true); try { - await getActiveSubscriptions(); + // Refresh both active subscriptions and available purchases + const [activeSubs, purchases] = await Promise.all([ + getActiveSubscriptions(), + getAvailablePurchases(), + ]); + setCachedAvailablePurchases(purchases); + console.log('Refreshed active subscriptions:', activeSubs); + console.log('Refreshed available purchases:', purchases); + + // For iOS, check if there's a pending change + if (Platform.OS === 'ios') { + const premiumPurchases = purchases.filter( + (p) => + p.productId === 'dev.hyo.martie.premium' || + p.productId === 'dev.hyo.martie.premium_year', + ); + console.log( + 'Premium purchases found:', + premiumPurchases.map((p) => ({ + productId: p.productId, + transactionDate: new Date(p.transactionDate).toISOString(), + isAutoRenewing: p.isAutoRenewing, + })), + ); + } } catch (error) { console.error('Error checking subscription status:', error); } finally { @@ -995,21 +1242,14 @@ function SubscriptionFlowContainer() { subscriptionOffers: subscription && 'subscriptionOfferDetailsAndroid' in subscription && - subscription.subscriptionOfferDetailsAndroid - ? subscription.subscriptionOfferDetailsAndroid.map((offer) => { - // Store which base plan is being purchased - if (itemId === 'dev.hyo.martie.premium') { - (global as any).__lastPurchasedPlan = offer.basePlanId; - console.log( - 'Purchasing plan with basePlanId:', - offer.basePlanId, - ); - } - return { - sku: itemId, - offerToken: offer.offerToken, - }; - }) + (subscription as AndroidSubscriptionDetails) + .subscriptionOfferDetailsAndroid + ? ( + subscription as AndroidSubscriptionDetails + ).subscriptionOfferDetailsAndroid!.map((offer) => ({ + sku: itemId, + offerToken: offer.offerToken, + })) : [], }, }, @@ -1052,8 +1292,12 @@ function SubscriptionFlowContainer() { isProcessing={isProcessing} isCheckingStatus={isCheckingStatus} lastPurchase={lastPurchase} + lastPurchasedPlan={lastPurchasedPlan} + cachedAvailablePurchases={cachedAvailablePurchases} + setCachedAvailablePurchases={setCachedAvailablePurchases} setIsProcessing={setIsProcessing} setPurchaseResult={setPurchaseResult} + setLastPurchasedPlan={setLastPurchasedPlan} onSubscribe={handleSubscription} onRetryLoadSubscriptions={handleRetryLoadSubscriptions} onRefreshStatus={handleRefreshStatus} diff --git a/example/src/utils/constants.ts b/example/src/utils/constants.ts index ce6b00a5a..2f27b995b 100644 --- a/example/src/utils/constants.ts +++ b/example/src/utils/constants.ts @@ -14,5 +14,8 @@ export const PRODUCT_IDS: string[] = [ ...NON_CONSUMABLE_PRODUCT_IDS, ]; -export const SUBSCRIPTION_PRODUCT_IDS: string[] = ['dev.hyo.martie.premium']; +export const SUBSCRIPTION_PRODUCT_IDS: string[] = [ + 'dev.hyo.martie.premium', + 'dev.hyo.martie.premium_year', +]; export const DEFAULT_SUBSCRIPTION_PRODUCT_ID = SUBSCRIPTION_PRODUCT_IDS[0]; From eea6e27fef69eda774dec6ae32554e9a32abdf28 Mon Sep 17 00:00:00 2001 From: Hyo Date: Tue, 30 Sep 2025 02:46:19 +0900 Subject: [PATCH 5/6] docs: update subscription-flow.md --- docs/docs/examples/available-purchases.md | 8 +- docs/docs/examples/offer-code.md | 8 +- docs/docs/examples/purchase-flow.md | 6 +- docs/docs/examples/subscription-flow.md | 1198 +++++++++------------ example/screens/SubscriptionFlow.tsx | 79 +- 5 files changed, 563 insertions(+), 736 deletions(-) diff --git a/docs/docs/examples/available-purchases.md b/docs/docs/examples/available-purchases.md index f35f04ad7..a32afc37f 100644 --- a/docs/docs/examples/available-purchases.md +++ b/docs/docs/examples/available-purchases.md @@ -10,11 +10,11 @@ import AdFitTopFixed from "@site/src/uis/AdFitTopFixed"; -This example shows how to list and restore previously purchased items (non‑consumables and active subscriptions) using `getAvailablePurchases()` and `getActiveSubscriptions()`. +This guide demonstrates how to list and restore previously purchased items (non‑consumables and active subscriptions) using `getAvailablePurchases()` and `getActiveSubscriptions()`. -View the full example source: - -- GitHub: https://github.com/hyochan/react-native-iap/blob/main/example/app/available-purchases.tsx +:::note +The complete working example can be found at [example/screens/AvailablePurchases.tsx](https://github.com/hyochan/react-native-iap/blob/main/example/screens/AvailablePurchases.tsx). Note that the example code was heavily vibe-coded with Claude and is quite verbose/messy for demonstration purposes - use it as a reference only. +::: ## Restore Flow diff --git a/docs/docs/examples/offer-code.md b/docs/docs/examples/offer-code.md index 561ec6709..d3f040c6e 100644 --- a/docs/docs/examples/offer-code.md +++ b/docs/docs/examples/offer-code.md @@ -10,11 +10,11 @@ import AdFitTopFixed from "@site/src/uis/AdFitTopFixed"; -Redeem App Store offer/promo codes using the native iOS sheet. This is useful for subscription promotional codes and requires a real iOS device. +This guide demonstrates how to redeem App Store offer/promo codes using the native iOS sheet. This is useful for subscription promotional codes and requires a real iOS device. -View the full example source: - -- GitHub: https://github.com/hyochan/react-native-iap/blob/main/example/app/offer-code.tsx +:::note +The complete working example can be found at [example/screens/OfferCode.tsx](https://github.com/hyochan/react-native-iap/blob/main/example/screens/OfferCode.tsx). Note that the example code was heavily vibe-coded with Claude and is quite verbose/messy for demonstration purposes - use it as a reference only. +::: ## Usage diff --git a/docs/docs/examples/purchase-flow.md b/docs/docs/examples/purchase-flow.md index a5d7b2653..ed6e13ceb 100644 --- a/docs/docs/examples/purchase-flow.md +++ b/docs/docs/examples/purchase-flow.md @@ -10,7 +10,11 @@ import AdFitTopFixed from "@site/src/uis/AdFitTopFixed"; -This example walks through a clean purchase flow using react-native-iap with the `useIAP` hook and the new platform‑specific request shape. It mirrors the working sample in [example/screens/PurchaseFlow.tsx](https://github.com/hyochan/react-native-iap/blob/main/example/screens/PurchaseFlow.tsx). +This guide demonstrates a clean purchase flow using react-native-iap with the `useIAP` hook and the new platform‑specific request shape. + +:::note +The complete working example can be found at [example/screens/PurchaseFlow.tsx](https://github.com/hyochan/react-native-iap/blob/main/example/screens/PurchaseFlow.tsx). Note that the example code was heavily vibe-coded with Claude and is quite verbose/messy for demonstration purposes - use it as a reference only. +::: ## Flow Overview diff --git a/docs/docs/examples/subscription-flow.md b/docs/docs/examples/subscription-flow.md index 5d6ca5342..6ad02001d 100644 --- a/docs/docs/examples/subscription-flow.md +++ b/docs/docs/examples/subscription-flow.md @@ -12,798 +12,618 @@ import AdFitTopFixed from "@site/src/uis/AdFitTopFixed"; -This example walks through a practical subscriptions flow with react-native-iap. It mirrors the working sample in [example/screens/SubscriptionFlow.tsx](https://github.com/hyochan/react-native-iap/blob/main/example/screens/SubscriptionFlow.tsx), including status checks, renewal handling, and subscription management UI. +This guide demonstrates practical subscription scenarios with react-native-iap. -## Important: Platform-Specific Subscription Properties +:::note +The complete working example can be found at [example/screens/SubscriptionFlow.tsx](https://github.com/hyochan/react-native-iap/blob/main/example/screens/SubscriptionFlow.tsx). Note that the example code was heavily vibe-coded with Claude and is quite verbose/messy for demonstration purposes - use it as a reference only. +::: -When checking subscription status, different platforms provide different properties: +## 1. Purchasing a Subscription with `requestPurchase` -### iOS Subscription Properties - -- **`expirationDateIos`**: Unix timestamp (milliseconds) indicating when the subscription expires -- **`originalTransactionDateIos`**: Original purchase date -- **`environmentIos`**: Can be 'Production' or 'Sandbox' (useful for testing) - -### Android Subscription Properties - -- **`autoRenewingAndroid`**: Boolean indicating if the subscription will auto-renew -- **`purchaseStateAndroid`**: Purchase state (0 = purchased, 1 = canceled) -- **`obfuscatedAccountIdAndroid`**: Account identifier if provided during purchase - -### Key Differences - -- **iOS**: You must check `expirationDateIos` against current time to determine if active -- **Android**: You can check `autoRenewingAndroid` - if false, the user has canceled - -⚠️ **Note**: Always validate subscription status on your server for production apps. Client-side checks are useful for UI updates but should not be the sole source of truth. - -## Complete Subscription Flow +### Basic Subscription Purchase ```tsx -import React, {useEffect, useState} from 'react'; -import { - View, - Text, - TouchableOpacity, - Alert, - StyleSheet, - ScrollView, - ActivityIndicator, - Platform, -} from 'react-native'; import {useIAP} from 'react-native-iap'; +import {Platform} from 'react-native'; -// Subscription product IDs -const SUBSCRIPTION_SKUS = [ - 'com.yourapp.premium_monthly', - 'com.yourapp.premium_yearly', -]; - -interface SubscriptionStatus { - isActive: boolean; - productId?: string; - expirationDate?: Date; - autoRenewing?: boolean; - inGracePeriod?: boolean; -} - -export default function SubscriptionManager() { +function SubscriptionPurchase() { const { connected, subscriptions, - currentPurchase, - currentPurchaseError, - fetchProducts, - getAvailablePurchases, requestPurchase, - finishTransaction, + fetchProducts, } = useIAP(); - const [loading, setLoading] = useState(false); - const [subscriptionStatus, setSubscriptionStatus] = - useState({ - isActive: false, - }); - - // Initialize and load subscriptions useEffect(() => { + // Load subscription products if (connected) { - loadSubscriptions(); - checkSubscriptionStatus(); + fetchProducts({ + skus: ['com.app.premium_monthly', 'com.app.premium_yearly'], + type: 'subs', + }); } }, [connected]); - // Handle subscription purchases - useEffect(() => { - if (currentPurchase) { - handleSubscriptionPurchase(currentPurchase); - } - }, [currentPurchase]); - - // Handle purchase errors - useEffect(() => { - if (currentPurchaseError) { - handlePurchaseError(currentPurchaseError); + const purchaseSubscription = async (productId: string) => { + if (!connected) { + Alert.alert('Error', 'Store not connected'); + return; } - }, [currentPurchaseError]); - const loadSubscriptions = async () => { try { - setLoading(true); - await fetchProducts({skus: SUBSCRIPTION_SKUS, type: 'subs'}); - console.log('Subscriptions loaded'); - } catch (error) { - console.error('Failed to load subscriptions:', error); - Alert.alert('Error', 'Failed to load subscription options'); - } finally { - setLoading(false); - } - }; + // Find the subscription product + const subscription = subscriptions.find(sub => sub.id === productId); + if (!subscription) { + throw new Error('Subscription not found'); + } - const checkSubscriptionStatus = async () => { - try { - // In hook: updates state, does not return purchases - await getAvailablePurchases(); - const activeSubscription = findActiveSubscription(availablePurchases); + // Platform-specific purchase request + await requestPurchase({ + request: { + ios: { + sku: productId, + andDangerouslyFinishTransactionAutomatically: false, + }, + android: { + skus: [productId], + // Android requires subscriptionOffers for subscriptions + subscriptionOffers: subscription.subscriptionOfferDetailsAndroid + ?.map(offer => ({ + sku: subscription.id, + offerToken: offer.offerToken, + })) || [], + }, + }, + type: 'subs', + }); + + // Success handling is done in onPurchaseSuccess callback - if (activeSubscription) { - const status = await validateSubscriptionStatus(activeSubscription); - setSubscriptionStatus(status); - } else { - setSubscriptionStatus({isActive: false}); - } } catch (error) { - console.error('Failed to check subscription status:', error); + console.error('Purchase failed:', error); + Alert.alert('Error', 'Failed to purchase subscription'); } }; - const findActiveSubscription = (purchases) => { - // Find subscriptions and check if they're still active - return purchases.find((purchase) => { - if (!SUBSCRIPTION_SKUS.includes(purchase.productId)) { - return false; - } - // Check if the subscription is still active - return isSubscriptionActive(purchase); - }); - }; + return ( + + {subscriptions.map(sub => ( + purchaseSubscription(sub.id)} + > + {sub.title} - {sub.localizedPrice} + + ))} + + ); +} +``` - /** - * Platform-specific subscription status checking - * iOS: Uses expirationDateIos to check if subscription is expired - * Android: Uses autoRenewingAndroid to check renewal status - */ - const isSubscriptionActive = (purchase) => { - const currentTime = Date.now(); - - // Check platform-specific subscription properties - if (Platform.OS === 'ios') { - // iOS: Check expiration date - if (purchase.expirationDateIos) { - console.log( - 'iOS Subscription expiration:', - new Date(purchase.expirationDateIos).toISOString(), - ); - return purchase.expirationDateIos > currentTime; - } +### Handling Purchase Success with Hook Callbacks - // For sandbox/development environment - if (purchase.environmentIOS === 'Sandbox') { - console.log('iOS Sandbox environment detected'); - // In sandbox, also check if it's a recent purchase (within 24 hours) - const dayInMs = 24 * 60 * 60 * 1000; - if ( - purchase.transactionDate && - currentTime - purchase.transactionDate < dayInMs - ) { - return true; - } - } - } else if (Platform.OS === 'android') { - // Android: Check auto-renewing status - if (purchase.autoRenewingAndroid !== undefined) { - console.log( - 'Android auto-renewing status:', - purchase.autoRenewingAndroid, - ); - return purchase.autoRenewingAndroid; - } +```tsx +function SubscriptionManager() { + const [activeSubscription, setActiveSubscription] = useState(null); - // Fallback: Check if purchase is recent (within 30 days for monthly subscriptions) - const monthInMs = 30 * 24 * 60 * 60 * 1000; - if ( - purchase.transactionDate && - currentTime - purchase.transactionDate < monthInMs - ) { - return true; - } - } + const { + connected, + subscriptions, + requestPurchase, + finishTransaction, + } = useIAP({ + onPurchaseSuccess: async (purchase) => { + console.log('Purchase successful:', purchase.productId); - // If we can't determine status, assume inactive - return false; - }; + // Validate with your server + const isValid = await validatePurchaseOnServer(purchase); - const validateSubscriptionStatus = async (purchase) => { - try { - // Validate subscription on your server - const response = await fetch( - 'https://your-server.com/validate-subscription', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - productId: purchase.productId, - purchaseToken: purchase.purchaseToken, // Unified field (iOS: JWS, Android: purchaseToken) - packageName: (purchase as PurchaseAndroid)?.packageNameAndroid, // Will be needed in android - }), - }, - ); + if (isValid) { + // Update local state + setActiveSubscription(purchase.productId); - const result = await response.json(); + // Finish the transaction + await finishTransaction({purchase}); - return { - isActive: result.isActive, - productId: purchase.productId, - expirationDate: new Date(result.expirationDate), - autoRenewing: result.autoRenewing, - inGracePeriod: result.inGracePeriod, - }; - } catch (error) { - console.error('Subscription validation error:', error); - return {isActive: false}; - } + Alert.alert('Success', 'Subscription activated!'); + } + }, + onPurchaseError: (error) => { + if (error.code !== 'E_USER_CANCELLED') { + Alert.alert('Error', error.message); + } + }, + }); + + // Purchase function remains simple + const subscribe = async (productId: string) => { + const subscription = subscriptions.find(s => s.id === productId); + if (!subscription) return; + + await requestPurchase({ + request: { + ios: { + sku: productId, + andDangerouslyFinishTransactionAutomatically: false, + }, + android: { + skus: [productId], + subscriptionOffers: subscription.subscriptionOfferDetailsAndroid + ?.map(offer => ({ + sku: subscription.id, + offerToken: offer.offerToken, + })) || [], + }, + }, + type: 'subs', + }); + // Don't handle success here - use onPurchaseSuccess callback }; +} +``` - const handleSubscriptionPurchase = async (purchase) => { - try { - console.log('Processing subscription purchase:', purchase.productId); - - // Validate the subscription purchase - const subscriptionInfo = await validateSubscriptionStatus(purchase); +## 2. Checking Subscription Status with `getActiveSubscriptions` - if (subscriptionInfo.isActive) { - // Grant subscription benefits - await grantSubscriptionBenefits(purchase); +### Basic Status Check After Purchase - // Update local status - setSubscriptionStatus(subscriptionInfo); +```tsx +import {useIAP} from 'react-native-iap'; +import {Platform} from 'react-native'; - // Finish the transaction - await finishTransaction({purchase}); +function useSubscriptionStatus() { + const {getActiveSubscriptions} = useIAP(); + const [isSubscribed, setIsSubscribed] = useState(false); + const [subscriptionDetails, setSubscriptionDetails] = useState(null); - Alert.alert( - 'Subscription Activated', - `Welcome to Premium! Your subscription is now active.`, - ); + const checkSubscriptionStatus = async () => { + try { + // Get active subscriptions - returns array of active subscriptions + const activeSubscriptions = await getActiveSubscriptions(); + + if (activeSubscriptions.length > 0) { + // User has at least one active subscription + setIsSubscribed(true); + + // Check specific subscription details + const subscription = activeSubscriptions[0]; + + // Platform-specific status checks + if (Platform.OS === 'ios') { + // iOS provides expirationDateIos + const isExpired = subscription.expirationDateIos < Date.now(); + setSubscriptionDetails({ + productId: subscription.productId, + isActive: !isExpired, + expiresAt: new Date(subscription.expirationDateIos), + environment: subscription.environmentIOS, // 'Production' or 'Sandbox' + }); + } else { + // Android provides autoRenewingAndroid + setSubscriptionDetails({ + productId: subscription.productId, + isActive: subscription.autoRenewingAndroid, + willAutoRenew: subscription.autoRenewingAndroid, + purchaseState: subscription.purchaseStateAndroid, // 0 = purchased, 1 = canceled + }); + } } else { - Alert.alert('Error', 'Subscription validation failed'); + setIsSubscribed(false); + setSubscriptionDetails(null); } } catch (error) { - console.error('Error processing subscription:', error); - Alert.alert('Error', 'Failed to activate subscription'); + console.error('Failed to check subscription status:', error); } }; - const handlePurchaseError = (error) => { - console.error('Subscription purchase error:', error); + return {isSubscribed, subscriptionDetails, checkSubscriptionStatus}; +} +``` - switch (error.code) { - case 'E_USER_CANCELLED': - // User cancelled - no action needed - break; - case 'E_ALREADY_OWNED': - Alert.alert( - 'Already Subscribed', - 'You already have an active subscription. Check your subscription status.', - ); - checkSubscriptionStatus(); // Refresh status - break; - default: - Alert.alert( - 'Subscription Failed', - error.message || 'Unknown error occurred', - ); - break; - } - }; +### Checking Multiple Subscription Tiers - const grantSubscriptionBenefits = async (purchase) => { - try { - // Grant subscription benefits on your server - await fetch('https://your-server.com/grant-subscription', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - userId: 'current-user-id', - productId: purchase.productId, - transactionId: purchase.transactionId, - }), - }); +```tsx +const SUBSCRIPTION_SKUS = { + BASIC: 'com.app.basic_monthly', + PREMIUM: 'com.app.premium_monthly', + PREMIUM_YEARLY: 'com.app.premium_yearly', +}; - console.log('Subscription benefits granted'); - } catch (error) { - console.error('Failed to grant subscription benefits:', error); - throw error; - } - }; +async function getUserSubscriptionTier() { + const {getActiveSubscriptions} = useIAP(); - /** - * Platform-Specific Subscription Purchase Options - * - * Android: Requires subscriptionOffers array with offer tokens from fetchProducts() - * subscriptionOffers: Array of AndroidSubscriptionOfferInput objects containing sku and offerToken - * Each offer token corresponds to a specific pricing plan (base plan, introductory offer, etc.) - * Without subscriptionOffers, Android purchases will fail with "number of skus (1) must match: the number of offerTokens (0)" - * - * iOS: Optional withOffer for promotional discounts - * withOffer: DiscountOfferInputIOS object for applying promotional offers - * Includes offerIdentifier, keyIdentifier, nonce, signature, and timestamp - * Only needed when applying specific promotional offers to the purchase - */ - const purchaseSubscription = async (productId) => { - if (!connected) { - Alert.alert('Error', 'Store is not connected'); - return; - } + try { + const activeSubscriptions = await getActiveSubscriptions(); - try { - console.log('Requesting subscription:', productId); + // Check for premium yearly first (highest tier) + const yearlyPremium = activeSubscriptions.find( + sub => sub.productId === SUBSCRIPTION_SKUS.PREMIUM_YEARLY + ); + if (yearlyPremium) return 'PREMIUM_YEARLY'; - // Find the subscription product to get offer details - const subscription = subscriptions.find((sub) => sub.id === productId); - if (!subscription) { - Alert.alert('Error', 'Subscription product not found'); - return; - } + // Then check monthly premium + const monthlyPremium = activeSubscriptions.find( + sub => sub.productId === SUBSCRIPTION_SKUS.PREMIUM + ); + if (monthlyPremium) return 'PREMIUM'; - // 2) Build subscriptionOffers from the fetched product details - const subscriptionOffers = ( - subscription.subscriptionOfferDetailsAndroid ?? [] - ).map((offer) => ({ - sku: subscription.id, - offerToken: offer.offerToken, - })); - - // Platform-specific subscription purchase requests - // For Android: subscriptionOffers are required for subscriptions to specify pricing plans and offer tokens - // For iOS: withOffer can be used for promotional offers or discounts - await requestPurchase({ - request: { - ios: { - sku: productId, - andDangerouslyFinishTransactionAutomatically: false, - // withOffer: { /* DiscountOfferInputIOS for promotional offers */ } - }, - android: { - skus: [productId], - subscriptionOffers, - }, - }, - type: 'subs', - }); - } catch (error) { - console.error('Subscription request failed:', error); - Alert.alert('Error', 'Failed to start subscription purchase'); - } - }; + // Finally check basic + const basic = activeSubscriptions.find( + sub => sub.productId === SUBSCRIPTION_SKUS.BASIC + ); + if (basic) return 'BASIC'; - const openSubscriptionManagement = () => { - import('react-native-iap').then(({deepLinkToSubscriptions}) => { - deepLinkToSubscriptions({skuAndroid: 'your_subscription_sku'}); - }); - }; + return 'FREE'; + } catch (error) { + console.error('Failed to get subscription tier:', error); + return 'FREE'; + } +} +``` - const restoreSubscriptions = async () => { - try { - setLoading(true); - await checkSubscriptionStatus(); - Alert.alert('Restore Complete', 'Subscription status has been updated'); - } catch (error) { - Alert.alert('Error', 'Failed to restore subscriptions'); - } finally { - setLoading(false); - } - }; +## 3. Subscription Plan Changes (Upgrade/Downgrade) - const formatDate = (date: Date) => { - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); - }; +### iOS: Automatic Subscription Group Management - const renderSubscriptionStatus = () => { - if (subscriptionStatus.isActive) { - return ( - - Premium Active - - Your premium subscription is active - - - {subscriptionStatus.expirationDate && ( - - {subscriptionStatus.autoRenewing ? 'Renews' : 'Expires'} on{' '} - {formatDate(subscriptionStatus.expirationDate)} - - )} - - {subscriptionStatus.inGracePeriod && ( - - Your subscription is in grace period. Please update your payment - method. - - )} - - - Manage Subscription - - - ); - } +On iOS, subscriptions in the same subscription group automatically replace each other when purchased. The App Store handles the proration and timing automatically. - return ( - - No Active Subscription - - Subscribe to unlock premium features - - - ); - }; +```tsx +// iOS Subscription Configuration in App Store Connect: +// Subscription Group: "Premium Access" +// - com.app.premium_monthly (Rank 1) +// - com.app.premium_yearly (Rank 2 - higher rank = better value) - const renderSubscriptionOption = (subscription) => { - const isYearly = subscription.productId.includes('yearly'); - const savings = isYearly ? '2 months free!' : null; - - return ( - - - - {isYearly ? 'Yearly Premium' : 'Monthly Premium'} - - - {subscription.localizedPrice} - - {subscription.subscriptionPeriod && ( - - per {subscription.subscriptionPeriod} - - )} - {savings && {savings}} - +async function handleIOSSubscriptionChange(newProductId: string) { + const {requestPurchase, getActiveSubscriptions} = useIAP(); - purchaseSubscription(subscription.productId)} - disabled={loading || subscriptionStatus.isActive} - > - - {subscriptionStatus.isActive ? 'Active' : 'Subscribe'} - - - + try { + // Check current subscription + const currentSubs = await getActiveSubscriptions(); + const currentSub = currentSubs.find(sub => + sub.productId === 'com.app.premium_monthly' || + sub.productId === 'com.app.premium_yearly' ); - }; - if (!connected) { - return ( - - - Connecting to store... - - ); - } + if (currentSub) { + console.log(`Changing from ${currentSub.productId} to ${newProductId}`); + // iOS automatically handles the switch when both products are in the same group + } - return ( - - Subscription Management + // Simply purchase the new subscription + // iOS will automatically: + // 1. Cancel the old subscription at the end of the current period + // 2. Start the new subscription + // 3. Handle any necessary proration + await requestPurchase({ + request: { + ios: { + sku: newProductId, + andDangerouslyFinishTransactionAutomatically: false, + }, + android: { + skus: [newProductId], + }, + }, + type: 'subs', + }); - {renderSubscriptionStatus()} + Alert.alert( + 'Subscription Updated', + 'Your subscription will change at the end of the current billing period.' + ); - Subscription Options + } catch (error) { + console.error('Subscription change failed:', error); + } +} - {loading ? ( - - - Loading subscriptions... - - ) : ( - {subscriptions.map(renderSubscriptionOption)} - )} +// Usage example +function IOSSubscriptionManager() { + const handleUpgradeToYearly = () => { + handleIOSSubscriptionChange('com.app.premium_yearly'); + }; - - - Restore Purchases - - - - - - Subscriptions auto-renew unless cancelled. You can manage your - subscriptions in your device settings. - - - + const handleDowngradeToMonthly = () => { + handleIOSSubscriptionChange('com.app.premium_monthly'); + }; + + return ( + + iOS subscriptions in the same group auto-replace each other +