From fdbf36e1934e0d150029466b8e430fa5ae0ca573 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Thu, 5 Feb 2026 21:45:10 +0800 Subject: [PATCH] fix: restore Pro purchases after Firebase login and improve restore feedback When a user logs into their Zaparoo/Firebase account, RevenueCat switches to a customer profile for that Firebase UID. If the user purchased Pro before logging in, the purchase remains orphaned on the anonymous RevenueCat user. This fix: - Auto-restores purchases after Firebase login to transfer orphaned purchases - Shows "No purchases found" toast when restore succeeds but no entitlement exists - Adds optional chaining to prevent crashes when entitlements object is malformed --- src/App.tsx | 17 +++- .../unit/components/ProPurchase.test.tsx | 95 ++++++++++++++++++- src/components/ProPurchase.tsx | 11 ++- src/translations/en-US.json | 1 + 4 files changed, 117 insertions(+), 7 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d465903..95d97ab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -255,7 +255,19 @@ export default function App() { const { customerInfo } = await Purchases.logIn({ appUserID: change.user.uid, }); - const hasAccess = !!customerInfo.entitlements.active.tapto_launcher; + let hasAccess = !!customerInfo.entitlements.active?.tapto_launcher; + + // Restore purchases to transfer any orphaned purchases from anonymous user + if (!hasAccess) { + try { + await Purchases.restorePurchases(); + const restored = await Purchases.getCustomerInfo(); + hasAccess = + !!restored.customerInfo.entitlements.active?.tapto_launcher; + } catch (e) { + logger.warn("Auto-restore after login failed:", e); + } + } setLauncherAccess(hasAccess); // Also check API premium status (online subscription) @@ -275,7 +287,8 @@ export default function App() { // Revert to anonymous RevenueCat customer await Purchases.logOut(); const { customerInfo } = await Purchases.getCustomerInfo(); - const hasAccess = !!customerInfo.entitlements.active.tapto_launcher; + const hasAccess = + !!customerInfo.entitlements.active?.tapto_launcher; setLauncherAccess(hasAccess); } } catch (e) { diff --git a/src/__tests__/unit/components/ProPurchase.test.tsx b/src/__tests__/unit/components/ProPurchase.test.tsx index 7482c80..eade6dd 100644 --- a/src/__tests__/unit/components/ProPurchase.test.tsx +++ b/src/__tests__/unit/components/ProPurchase.test.tsx @@ -155,7 +155,100 @@ describe("RestorePuchasesButton", () => { }); const toast = await import("react-hot-toast"); - expect(toast.default.error).toHaveBeenCalled(); + expect(toast.default.error).toHaveBeenCalledWith( + "settings.app.restoreFail", + ); + }); + + it("should show not found toast when restore succeeds but no entitlement exists", async () => { + const mockCustomerInfo = { + customerInfo: { + entitlements: { + active: {}, // No tapto_launcher entitlement + }, + }, + } as any; + + const { Purchases } = await import("@revenuecat/purchases-capacitor"); + vi.mocked(Purchases.restorePurchases).mockResolvedValue({} as any); + vi.mocked(Purchases.getCustomerInfo).mockResolvedValue(mockCustomerInfo); + + render(); + + const button = screen.getByRole("button", { + name: "settings.app.restorePurchases", + }); + fireEvent.click(button); + + await waitFor(() => { + expect(Purchases.restorePurchases).toHaveBeenCalled(); + expect(Purchases.getCustomerInfo).toHaveBeenCalled(); + }); + + const toast = await import("react-hot-toast"); + expect(toast.default.error).toHaveBeenCalledWith( + "settings.app.restoreNotFound", + ); + }); + + it("should handle undefined entitlements.active gracefully", async () => { + const mockCustomerInfo = { + customerInfo: { + entitlements: { + active: undefined, + }, + }, + } as any; + + const { Purchases } = await import("@revenuecat/purchases-capacitor"); + vi.mocked(Purchases.restorePurchases).mockResolvedValue({} as any); + vi.mocked(Purchases.getCustomerInfo).mockResolvedValue(mockCustomerInfo); + + render(); + + const button = screen.getByRole("button", { + name: "settings.app.restorePurchases", + }); + fireEvent.click(button); + + await waitFor(() => { + expect(Purchases.restorePurchases).toHaveBeenCalled(); + expect(Purchases.getCustomerInfo).toHaveBeenCalled(); + }); + + const toast = await import("react-hot-toast"); + // Should show "not found" toast, not crash + expect(toast.default.error).toHaveBeenCalledWith( + "settings.app.restoreNotFound", + ); + }); + + it("should handle missing entitlements object gracefully", async () => { + const mockCustomerInfo = { + customerInfo: {}, // No entitlements at all + } as any; + + const { Purchases } = await import("@revenuecat/purchases-capacitor"); + vi.mocked(Purchases.restorePurchases).mockResolvedValue({} as any); + vi.mocked(Purchases.getCustomerInfo).mockResolvedValue(mockCustomerInfo); + + render(); + + const button = screen.getByRole("button", { + name: "settings.app.restorePurchases", + }); + fireEvent.click(button); + + await waitFor(() => { + expect(Purchases.restorePurchases).toHaveBeenCalled(); + expect(Purchases.getCustomerInfo).toHaveBeenCalled(); + }); + + const toast = await import("react-hot-toast"); + // Should show "not found" toast, not crash + expect(toast.default.error).toHaveBeenCalledWith( + "settings.app.restoreNotFound", + ); }); }); diff --git a/src/components/ProPurchase.tsx b/src/components/ProPurchase.tsx index b462191..c1b5259 100644 --- a/src/components/ProPurchase.tsx +++ b/src/components/ProPurchase.tsx @@ -32,15 +32,18 @@ export const RestorePuchasesButton = () => { logger.log("Restore purchases - customer info:", { entitlements: info.customerInfo.entitlements, activeEntitlements: Object.keys( - info.customerInfo.entitlements.active || {}, + info.customerInfo.entitlements?.active || {}, ), hasTaptoLauncher: - !!info.customerInfo.entitlements.active?.tapto_launcher, + !!info.customerInfo.entitlements?.active?.tapto_launcher, }); - if (info.customerInfo.entitlements.active.tapto_launcher) { + if (info.customerInfo.entitlements?.active?.tapto_launcher) { setLauncherAccess(true); notification("success"); toast.success(t("settings.app.restoreSuccess")); + } else { + // No active Pro entitlement found - inform user + toast.error(t("settings.app.restoreNotFound")); } } catch (e) { logger.error("restore purchases error", e, { @@ -162,7 +165,7 @@ export const useProPurchase = () => { // Fallback if not hydrated yet (shouldn't happen normally) Purchases.getCustomerInfo() .then((info) => { - if (info.customerInfo.entitlements.active.tapto_launcher) { + if (info.customerInfo.entitlements?.active?.tapto_launcher) { setProAccess(true); } else { setProAccess(false); diff --git a/src/translations/en-US.json b/src/translations/en-US.json index 4a61869..095cc80 100644 --- a/src/translations/en-US.json +++ b/src/translations/en-US.json @@ -458,6 +458,7 @@ "preferRemoteWriter": "Prefer connected reader for writing", "restorePurchases": "Restore purchases", "restoreSuccess": "Purchases have been restored", + "restoreNotFound": "No purchases found to restore", "restoreFail": "Failed to restore purchases", "shakeToLaunch": "Shake to launch", "proFeature": "Pro",