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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
95 changes: 94 additions & 1 deletion src/__tests__/unit/components/ProPurchase.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<RestorePuchasesButton />);

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(<RestorePuchasesButton />);

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(<RestorePuchasesButton />);

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",
);
});
});

Expand Down
11 changes: 7 additions & 4 deletions src/components/ProPurchase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/translations/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down