- {appService?.isIOSApp ? (
+ {appService?.hasIAP ? (
diff --git a/apps/readest-app/src/app/user/subscription/success/page.tsx b/apps/readest-app/src/app/user/subscription/success/page.tsx
index 9ef62b33..547b7c18 100644
--- a/apps/readest-app/src/app/user/subscription/success/page.tsx
+++ b/apps/readest-app/src/app/user/subscription/success/page.tsx
@@ -9,6 +9,7 @@ import Spinner from '@/components/Spinner';
const STRIPE_CHECK_URL = `${getAPIBaseUrl()}/stripe/check`;
const APPLE_IAP_VERIFY_URL = `${getNodeAPIBaseUrl()}/apple/iap-verify`;
+const ANDROID_IAP_VERIFY_URL = `${getNodeAPIBaseUrl()}/google/iap-verify`;
interface SessionStatus {
status: 'loading' | 'complete' | 'failed' | 'processing';
@@ -31,9 +32,17 @@ const SuccessPageWithSearchParams = () => {
const payment = searchParams?.get('payment');
const platform = searchParams?.get('platform');
const sessionId = searchParams?.get('session_id');
+
+ // iOS parameters
const transactionId = searchParams?.get('transaction_id');
const originalTransactionId = searchParams?.get('original_transaction_id');
+ // Android parameters
+ const packageName = searchParams?.get('package_name');
+ const productId = searchParams?.get('product_id');
+ const purchaseToken = searchParams?.get('purchase_token');
+ const orderId = searchParams?.get('order_id');
+
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
@@ -81,8 +90,7 @@ const SuccessPageWithSearchParams = () => {
}
};
- const updateIAPSessionStatus = async (
- platform: string,
+ const updateIOSIAPSessionStatus = async (
transactionId: string,
originalTransactionId: string,
) => {
@@ -90,55 +98,124 @@ const SuccessPageWithSearchParams = () => {
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
return;
}
- if (platform === 'ios') {
- try {
- const token = await getAccessToken();
- const response = await fetch(APPLE_IAP_VERIFY_URL, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${token}`,
- },
- body: JSON.stringify({
- transactionId,
- originalTransactionId,
- }),
- });
+ try {
+ const token = await getAccessToken();
+ const response = await fetch(APPLE_IAP_VERIFY_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ transactionId,
+ originalTransactionId,
+ }),
+ });
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const { purchase, error } = await response.json();
-
- if (error) {
- setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
- console.error('IAP verification error:', error);
- return;
- }
-
- setSessionStatus({
- status: purchase.status === 'active' ? 'complete' : 'failed',
- customerEmail: purchase.customerEmail || '',
- subscriptionId: purchase.subscriptionId,
- planName: purchase.planName,
- });
-
- try {
- await supabase.auth.refreshSession();
- } catch {}
- } catch (error) {
- console.error('Failed to verify IAP transaction:', error);
- setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
}
+
+ const { purchase, error } = await response.json();
+
+ if (error) {
+ setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
+ console.error('IAP verification error:', error);
+ return;
+ }
+
+ setSessionStatus({
+ status: purchase.status === 'active' ? 'complete' : 'failed',
+ customerEmail: purchase.customerEmail || '',
+ subscriptionId: purchase.subscriptionId,
+ planName: purchase.planName,
+ });
+
+ try {
+ await supabase.auth.refreshSession();
+ } catch {}
+ } catch (error) {
+ console.error('Failed to verify IAP transaction:', error);
+ setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
+ }
+ };
+
+ const updateAndroidIAPSessionStatus = async (
+ packageName: string,
+ productId: string,
+ orderId: string,
+ purchaseToken: string,
+ ) => {
+ if (!purchaseToken || !productId || !packageName) {
+ console.error('Missing required Android IAP parameters');
+ setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
+ return;
+ }
+
+ try {
+ const token = await getAccessToken();
+ const response = await fetch(ANDROID_IAP_VERIFY_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ purchaseToken,
+ orderId,
+ productId,
+ packageName,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const { purchase, error } = await response.json();
+
+ if (error) {
+ setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
+ console.error('Android IAP verification error:', error);
+ return;
+ }
+
+ setSessionStatus({
+ status: purchase.status === 'active' ? 'complete' : 'failed',
+ customerEmail: purchase.customerEmail || '',
+ subscriptionId: purchase.subscriptionId || purchase.orderId,
+ planName: purchase.planName,
+ amount: purchase.priceAmountMicros
+ ? Number(purchase.priceAmountMicros) / 1000000
+ : undefined,
+ currency: purchase.priceCurrencyCode,
+ });
+
+ try {
+ await supabase.auth.refreshSession();
+ } catch {}
+ } catch (error) {
+ console.error('Failed to verify Android IAP transaction:', error);
+ setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
+ }
+ };
+
+ const updateIAPSessionStatus = async () => {
+ if (platform === 'ios' && transactionId && originalTransactionId) {
+ await updateIOSIAPSessionStatus(transactionId, originalTransactionId);
+ } else if (platform === 'android' && orderId && purchaseToken && productId && packageName) {
+ await updateAndroidIAPSessionStatus(packageName, productId, orderId, purchaseToken);
+ } else {
+ console.error('Invalid IAP platform or missing parameters');
+ setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
}
};
const updateSessionStatus = async () => {
if (payment === 'stripe' && sessionId) {
await updateStripeSessionStatus();
- } else if (payment === 'iap' && platform && transactionId && originalTransactionId) {
- await updateIAPSessionStatus(platform, transactionId, originalTransactionId);
+ } else if (payment === 'iap') {
+ await updateIAPSessionStatus();
} else {
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
}
diff --git a/apps/readest-app/src/components/Providers.tsx b/apps/readest-app/src/components/Providers.tsx
index 341ffba6..3c503bc7 100644
--- a/apps/readest-app/src/components/Providers.tsx
+++ b/apps/readest-app/src/components/Providers.tsx
@@ -55,7 +55,14 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
}
});
}
- }, [envConfig, appService, applyUILanguage, setScreenBrightness, applyBackgroundTexture]);
+ }, [
+ envConfig,
+ appService,
+ applyUILanguage,
+ setScreenBrightness,
+ applyBackgroundTexture,
+ applyEinkMode,
+ ]);
// Make sure appService is available in all children components
if (!appService) return;
diff --git a/apps/readest-app/src/libs/iap/google/verifier.ts b/apps/readest-app/src/libs/iap/google/verifier.ts
new file mode 100644
index 00000000..29d05955
--- /dev/null
+++ b/apps/readest-app/src/libs/iap/google/verifier.ts
@@ -0,0 +1,281 @@
+import { google, androidpublisher_v3 } from 'googleapis';
+import { GoogleAuth, GoogleAuthOptions } from 'google-auth-library';
+
+interface VerifyPurchaseParams {
+ purchaseToken: string;
+ productId: string;
+ packageName: string;
+}
+
+export interface SubscriptionPurchase {
+ kind?: string | null;
+ startTimeMillis?: string | null;
+ expiryTimeMillis?: string | null;
+ autoRenewing?: boolean | null;
+ priceCurrencyCode?: string | null;
+ priceAmountMicros?: string | null;
+ countryCode?: string | null;
+ developerPayload?: string | null;
+ paymentState?: number | null;
+ cancelReason?: number | null;
+ userCancellationTimeMillis?: string | null;
+ orderId?: string | null;
+ linkedPurchaseToken?: string | null;
+ purchaseType?: number | null;
+ acknowledgementState?: number | null;
+ purchaseState?: number | null;
+ quantity?: number | null;
+ obfuscatedExternalAccountId?: string | null;
+ obfuscatedExternalProfileId?: string | null;
+}
+
+export interface ProductPurchase {
+ kind?: string | null;
+ purchaseTimeMillis?: string | null;
+ purchaseState?: number | null;
+ consumptionState?: number | null;
+ developerPayload?: string | null;
+ orderId?: string | null;
+ purchaseType?: number | null;
+ acknowledgementState?: number | null;
+ purchaseToken?: string | null;
+ productId?: string | null;
+ quantity?: number | null;
+ obfuscatedExternalAccountId?: string | null;
+ obfuscatedExternalProfileId?: string | null;
+ regionCode?: string | null;
+}
+
+export interface VerificationResult {
+ success: boolean;
+ error?: string;
+ status?: string;
+ purchaseDate?: Date;
+ expiresDate?: Date | null;
+ revocationDate?: Date | null;
+ revocationReason?: number | null;
+ purchaseData?: SubscriptionPurchase | ProductPurchase;
+ purchaseType?: 'subscription' | 'product';
+}
+
+export class GoogleIAPVerifier {
+ private auth: GoogleAuth;
+ private androidPublisher: androidpublisher_v3.Androidpublisher;
+
+ constructor() {
+ const authOptions: GoogleAuthOptions = {
+ scopes: ['https://www.googleapis.com/auth/androidpublisher'],
+ };
+
+ if (process.env['GOOGLE_IAP_SERVICE_ACCOUNT_KEY']) {
+ try {
+ authOptions.credentials = JSON.parse(process.env['GOOGLE_IAP_SERVICE_ACCOUNT_KEY']);
+ } catch (e) {
+ console.error('Failed to parse GOOGLE_SERVICE_ACCOUNT_KEY:', e);
+ throw new Error('Invalid Google service account credentials');
+ }
+ } else {
+ console.warn('Using Application Default Credentials');
+ }
+
+ this.auth = new GoogleAuth(authOptions);
+
+ this.androidPublisher = google.androidpublisher({
+ version: 'v3',
+ auth: this.auth,
+ });
+ }
+
+ async verifyPurchase(params: VerifyPurchaseParams): Promise