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",