feat(iap): support expanding cloud storage with IAP on iOS and Android (#2331)

This commit is contained in:
Huang Xin
2025-10-26 17:18:56 +08:00
committed by GitHub
parent 75f982e677
commit 9bc46914e2
51 changed files with 1065 additions and 749 deletions
@@ -378,7 +378,6 @@
"Email:": "البريد الإلكتروني:",
"Plan:": "الخطة:",
"Amount:": "المبلغ:",
"Subscription ID:": "رقم الاشتراك:",
"Go to Library": "الانتقال إلى المكتبة",
"Need help? Contact our support team at support@readest.com": "تحتاج مساعدة؟ تواصل مع فريق الدعم على support@readest.com",
"Free Plan": "الخطة المجانية",
@@ -651,5 +650,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "فتح سمات إضافية، خطوط، خيارات تخطيط، وخدمات قراءة بصوت عالٍ، ومترجمين، وخدمات تخزين سحابية.",
"Purchase Successful!": "تمت عملية الشراء بنجاح!",
"lifetime": "مدى الحياة",
"Thank you for your purchase! Your payment has been processed successfully.": "شكرًا لشرائك! تم معالجة الدفع بنجاح."
"Thank you for your purchase! Your payment has been processed successfully.": "شكرًا لشرائك! تم معالجة الدفع بنجاح.",
"Order ID:": "معرف الطلب:"
}
@@ -376,7 +376,6 @@
"Email:": "ইমেইল:",
"Plan:": "প্ল্যান:",
"Amount:": "পরিমাণ:",
"Subscription ID:": "সাবস্ক্রিপশন ID:",
"Go to Library": "লাইব্রেরিতে যান",
"Need help? Contact our support team at support@readest.com": "সাহায্য দরকার? support@readest.com এ আমাদের সাপোর্ট টিমের সাথে যোগাযোগ করুন",
"Free Plan": "ফ্রি প্ল্যান",
@@ -635,5 +634,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "অতিরিক্ত থিম, ফন্ট, লেআউট বিকল্প এবং উচ্চস্বরে পড়া, অনুবাদক, ক্লাউড স্টোরেজ পরিষেবাগুলি আনলক করুন।",
"Purchase Successful!": "ক্রয় সফল!",
"lifetime": "জীবনকাল",
"Thank you for your purchase! Your payment has been processed successfully.": "আপনার ক্রয়ের জন্য ধন্যবাদ! আপনার পেমেন্ট সফলভাবে প্রক্রিয়া করা হয়েছে।"
"Thank you for your purchase! Your payment has been processed successfully.": "আপনার ক্রয়ের জন্য ধন্যবাদ! আপনার পেমেন্ট সফলভাবে প্রক্রিয়া করা হয়েছে।",
"Order ID:": "অর্ডার আইডি:"
}
@@ -358,7 +358,6 @@
"Email:": "གློག་རྡུལ་ཡིག་ཟམ།",
"Plan:": "ཐུམ་སྒྲིལ།",
"Amount:": "དངུལ་གྲངས།",
"Subscription ID:": "མཚམས་སྦྱོར་ ID །",
"Go to Library": "དཔེ་མཛོད་ལ་འགྲོ།",
"Need help? Contact our support team at support@readest.com": "རོགས་རམ་དགོས་སམ། རྒྱབ་སྐྱོར་ཚོགས་པར་འབྲེལ་བ་གནང་རོགས། support@readest.com",
"Free Plan": "རིན་མེད་ཐུམ་སྒྲིལ།",
@@ -631,5 +630,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "অতিরিক্ত থিম, ফন্ট, লেআউট বিকল্প এবং উচ্চস্বরে পড়া, অনুবাদক, ক্লাউড স্টোরেজ পরিষেবাগুলি আনলক করুন।",
"Purchase Successful!": "ক্রয় সফল!",
"lifetime": "জীবনকাল",
"Thank you for your purchase! Your payment has been processed successfully.": "আপনার ক্রয়ের জন্য ধন্যবাদ! আপনার পেমেন্ট সফলভাবে প্রক্রিয়াকৃত হয়েছে।"
"Thank you for your purchase! Your payment has been processed successfully.": "আপনার ক্রয়ের জন্য ধন্যবাদ! আপনার পেমেন্ট সফলভাবে প্রক্রিয়াকৃত হয়েছে।",
"Order ID:": "অর্ডার আইডি:"
}
@@ -362,7 +362,6 @@
"Email:": "E-Mail:",
"Plan:": "Tarif:",
"Amount:": "Betrag:",
"Subscription ID:": "Abo-ID:",
"Go to Library": "Zur Bibliothek",
"Need help? Contact our support team at support@readest.com": "Brauchen Sie Hilfe? Kontaktieren Sie unser Support-Team unter support@readest.com",
"Free Plan": "Gratis-Tarif",
@@ -635,5 +634,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Zusätzliche Themen, Schriftarten, Layoutoptionen und Vorlesefunktionen, Übersetzer, Cloud-Speicherdienste freischalten.",
"Purchase Successful!": "Kauf erfolgreich!",
"lifetime": "lebenslang",
"Thank you for your purchase! Your payment has been processed successfully.": "Danke für Ihren Kauf! Ihre Zahlung wurde erfolgreich verarbeitet."
"Thank you for your purchase! Your payment has been processed successfully.": "Danke für Ihren Kauf! Ihre Zahlung wurde erfolgreich verarbeitet.",
"Order ID:": "Bestell-ID:"
}
@@ -363,7 +363,6 @@
"Email:": "Email:",
"Plan:": "Πρόγραμμα:",
"Amount:": "Ποσό:",
"Subscription ID:": "ID συνδρομής:",
"Go to Library": "Μετάβαση στη βιβλιοθήκη",
"Need help? Contact our support team at support@readest.com": "Χρειάζεστε βοήθεια; Επικοινωνήστε με την υποστήριξη στο support@readest.com",
"Free Plan": "Δωρεάν πρόγραμμα",
@@ -635,5 +634,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Ξεκλειδώστε επιπλέον θέματα, γραμματοσειρές, επιλογές διάταξης και δυνατότητες ανάγνωσης, μεταφραστές, υπηρεσίες αποθήκευσης cloud.",
"Purchase Successful!": "Η αγορά ήταν επιτυχής!",
"lifetime": "διαρκής",
"Thank you for your purchase! Your payment has been processed successfully.": "Σας ευχαριστούμε για την αγορά σας! Η πληρωμή σας έχει επεξεργαστεί με επιτυχία."
"Thank you for your purchase! Your payment has been processed successfully.": "Σας ευχαριστούμε για την αγορά σας! Η πληρωμή σας έχει επεξεργαστεί με επιτυχία.",
"Order ID:": "Αριθμός παραγγελίας:"
}
@@ -393,7 +393,6 @@
"Email:": "Correo electrónico:",
"Plan:": "Plan:",
"Amount:": "Importe:",
"Subscription ID:": "ID de suscripción:",
"Go to Library": "Ir a la biblioteca",
"Need help? Contact our support team at support@readest.com": "¿Necesitas ayuda? Contacta con nuestro equipo de soporte en support@readest.com",
"Free Plan": "Plan gratuito",
@@ -639,5 +638,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Desbloquear temas adicionales, fuentes, opciones de diseño y funciones de lectura en voz alta, traductores, servicios de almacenamiento en la nube.",
"Purchase Successful!": "¡Compra Exitosa!",
"lifetime": "de por vida",
"Thank you for your purchase! Your payment has been processed successfully.": "¡Gracias por tu compra! Tu pago se ha procesado correctamente."
"Thank you for your purchase! Your payment has been processed successfully.": "¡Gracias por tu compra! Tu pago se ha procesado correctamente.",
"Order ID:": "ID de pedido:"
}
@@ -362,7 +362,6 @@
"Email:": "ایمیل:",
"Plan:": "طرح:",
"Amount:": "مبلغ:",
"Subscription ID:": "شناسه اشتراک:",
"Go to Library": "رفتن به کتابخانه",
"Need help? Contact our support team at support@readest.com": "نیاز به کمک دارید؟ با تیم پشتیبانی ما به آدرس support@readest.com تماس بگیرید",
"Free Plan": "طرح رایگان",
@@ -635,5 +634,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "باز کردن تم‌ها، فونت‌ها، گزینه‌های چیدمان و خواندن با صدای بلند، مترجم‌ها، خدمات ذخیره‌سازی ابری اضافی.",
"Purchase Successful!": "خرید با موفقیت انجام شد!",
"lifetime": "مادام‌العمر",
"Thank you for your purchase! Your payment has been processed successfully.": "از خرید شما متشکریم! پرداخت شما با موفقیت انجام شد."
}
"Thank you for your purchase! Your payment has been processed successfully.": "از خرید شما متشکریم! پرداخت شما با موفقیت انجام شد.",
"Order ID:": "شناسه سفارش:"
}
@@ -366,7 +366,6 @@
"Email:": "E-mail:",
"Plan:": "Abonnement:",
"Amount:": "Montant:",
"Subscription ID:": "ID dabonnement:",
"Go to Library": "Aller à la bibliothèque",
"Need help? Contact our support team at support@readest.com": "Besoin daide? Contactez notre équipe à support@readest.com",
"Free Plan": "Abonnement gratuit",
@@ -639,5 +638,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Débloquer des thèmes supplémentaires, des polices, des options de mise en page et des fonctions de lecture à haute voix, des traducteurs, des services de stockage cloud.",
"Purchase Successful!": "Achat Réussi!",
"lifetime": "à vie",
"Thank you for your purchase! Your payment has been processed successfully.": "Merci pour votre achat ! Votre paiement a été traité avec succès."
"Thank you for your purchase! Your payment has been processed successfully.": "Merci pour votre achat ! Votre paiement a été traité avec succès.",
"Order ID:": "ID de commande :"
}
@@ -363,7 +363,6 @@
"Email:": "ईमेल:",
"Plan:": "योजना:",
"Amount:": "राशि:",
"Subscription ID:": "सदस्यता आईडी:",
"Go to Library": "लाइब्रेरी पर जाएँ",
"Need help? Contact our support team at support@readest.com": "मदद चाहिए? हमारी सहायता टीम से संपर्क करें: support@readest.com",
"Free Plan": "मुफ्त योजना",
@@ -635,5 +634,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "अतिरिक्त थीम, फ़ॉन्ट, लेआउट विकल्प और पढ़ने के लिए, अनुवादक, क्लाउड स्टोरेज सेवाएँ अनलॉक करें।",
"Purchase Successful!": "खरीदारी सफल!",
"lifetime": "जीवनकाल",
"Thank you for your purchase! Your payment has been processed successfully.": "आपकी खरीदारी के लिए धन्यवाद! आपका भुगतान सफलतापूर्वक संसाधित हो गया है।"
"Thank you for your purchase! Your payment has been processed successfully.": "आपकी खरीदारी के लिए धन्यवाद! आपका भुगतान सफलतापूर्वक संसाधित हो गया है।",
"Order ID:": "ऑर्डर आईडी:"
}
@@ -359,7 +359,6 @@
"Email:": "Email:",
"Plan:": "Paket:",
"Amount:": "Jumlah:",
"Subscription ID:": "ID Langganan:",
"Go to Library": "Pergi ke Perpustakaan",
"Need help? Contact our support team at support@readest.com": "Perlu bantuan? Hubungi tim dukungan kami di support@readest.com",
"Free Plan": "Paket Gratis",
@@ -631,5 +630,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Buka tema tambahan, font, opsi tata letak, dan baca nyaring, penerjemah, layanan penyimpanan cloud.",
"Purchase Successful!": "Pembelian Berhasil!",
"lifetime": "seumur hidup",
"Thank you for your purchase! Your payment has been processed successfully.": "Terima kasih atas pembelian Anda! Pembayaran Anda telah berhasil diproses."
"Thank you for your purchase! Your payment has been processed successfully.": "Terima kasih atas pembelian Anda! Pembayaran Anda telah berhasil diproses.",
"Order ID:": "ID Pesanan:"
}
@@ -367,7 +367,6 @@
"Email:": "Email:",
"Plan:": "Piano:",
"Amount:": "Importo:",
"Subscription ID:": "ID Abbonamento:",
"Go to Library": "Vai alla Libreria",
"Need help? Contact our support team at support@readest.com": "Hai bisogno di aiuto? Contatta il nostro supporto a support@readest.com",
"Free Plan": "Piano Gratuito",
@@ -639,5 +638,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Sblocca temi aggiuntivi, font, opzioni di layout e lettura ad alta voce, traduttori, servizi di archiviazione cloud.",
"Purchase Successful!": "Acquisto Riuscito!",
"lifetime": "a vita",
"Thank you for your purchase! Your payment has been processed successfully.": "Grazie per il tuo acquisto! Il pagamento è stato elaborato con successo."
"Thank you for your purchase! Your payment has been processed successfully.": "Grazie per il tuo acquisto! Il pagamento è stato elaborato con successo.",
"Order ID:": "ID Ordine:"
}
@@ -359,7 +359,6 @@
"Email:": "メール:",
"Plan:": "プラン:",
"Amount:": "金額:",
"Subscription ID:": "サブスクリプションID:",
"Go to Library": "ライブラリへ移動",
"Need help? Contact our support team at support@readest.com": "サポートが必要ですか? support@readest.com までお問い合わせください。",
"Free Plan": "無料プラン",
@@ -631,5 +630,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "追加のテーマ、フォント、レイアウトオプション、読み上げ、翻訳者、クラウドストレージサービスを解除します。",
"Purchase Successful!": "購入成功!",
"lifetime": "ライフタイム",
"Thank you for your purchase! Your payment has been processed successfully.": "ご購入ありがとうございます!お支払いが正常に処理されました。"
"Thank you for your purchase! Your payment has been processed successfully.": "ご購入ありがとうございます!お支払いが正常に処理されました。",
"Order ID:": "購入ID"
}
@@ -359,7 +359,6 @@
"Email:": "이메일:",
"Plan:": "요금제:",
"Amount:": "금액:",
"Subscription ID:": "구독 ID:",
"Go to Library": "라이브러리로 이동",
"Need help? Contact our support team at support@readest.com": "도움이 필요하시면 support@readest.com으로 문의해주세요.",
"Free Plan": "무료 요금제",
@@ -631,5 +630,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "추가 테마, 글꼴, 레이아웃 옵션 및 읽기, 번역기, 클라우드 스토리지 서비스를 잠금 해제합니다.",
"Purchase Successful!": "구매 성공!",
"lifetime": "평생",
"Thank you for your purchase! Your payment has been processed successfully.": "구매해주셔서 감사합니다! 결제가 성공적으로 처리되었습니다."
"Thank you for your purchase! Your payment has been processed successfully.": "구매해주셔서 감사합니다! 결제가 성공적으로 처리되었습니다.",
"Order ID:": "주문 ID:"
}
@@ -361,7 +361,6 @@
"Email:": "E-mail:",
"Plan:": "Abonnement:",
"Amount:": "Bedrag:",
"Subscription ID:": "Abonnements-ID:",
"Go to Library": "Ga naar bibliotheek",
"Need help? Contact our support team at support@readest.com": "Hulp nodig? Neem contact op met support via support@readest.com",
"Free Plan": "Gratis abonnement",
@@ -635,5 +634,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Ontgrendel extra thema's, lettertypen, lay-outopties en voorlezen, vertalers, cloudopslagdiensten.",
"Purchase Successful!": "Aankoop Succesvol!",
"lifetime": "levenslang",
"Thank you for your purchase! Your payment has been processed successfully.": "Bedankt voor je aankoop! Je betaling is succesvol verwerkt."
"Thank you for your purchase! Your payment has been processed successfully.": "Bedankt voor je aankoop! Je betaling is succesvol verwerkt.",
"Order ID:": "Bestel-ID:"
}
@@ -371,7 +371,6 @@
"Email:": "E-mail:",
"Plan:": "Plan:",
"Amount:": "Kwota:",
"Subscription ID:": "ID subskrypcji:",
"Go to Library": "Przejdź do biblioteki",
"Need help? Contact our support team at support@readest.com": "Potrzebujesz pomocy? Skontaktuj się z naszym zespołem wsparcia: support@readest.com",
"Free Plan": "Bezpłatny plan",
@@ -643,5 +642,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Odblokuj dodatkowe motywy, czcionki, opcje układu oraz funkcje czytania na głos, tłumacze, usługi przechowywania w chmurze.",
"Purchase Successful!": "Zakup Udany!",
"lifetime": "dożywotnio",
"Thank you for your purchase! Your payment has been processed successfully.": "Dziękujemy za zakup! Twoja płatność została pomyślnie przetworzona."
"Thank you for your purchase! Your payment has been processed successfully.": "Dziękujemy za zakup! Twoja płatność została pomyślnie przetworzona.",
"Order ID:": "ID zamówienia:"
}
@@ -367,7 +367,6 @@
"Email:": "E-mail:",
"Plan:": "Plano:",
"Amount:": "Valor:",
"Subscription ID:": "ID da assinatura:",
"Go to Library": "Ir para a biblioteca",
"Need help? Contact our support team at support@readest.com": "Precisa de ajuda? Entre em contato com nossa equipe de suporte: support@readest.com",
"Free Plan": "Plano gratuito",
@@ -639,5 +638,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Desbloquear temas adicionais, fontes, opções de layout e leitura em voz alta, tradutores, serviços de armazenamento em nuvem.",
"Purchase Successful!": "Compra Bem-Sucedida!",
"lifetime": "vitalício",
"Thank you for your purchase! Your payment has been processed successfully.": "Obrigado pela sua compra! Seu pagamento foi processado com sucesso."
"Thank you for your purchase! Your payment has been processed successfully.": "Obrigado pela sua compra! Seu pagamento foi processado com sucesso.",
"Order ID:": "ID do Pedido:"
}
@@ -371,7 +371,6 @@
"Email:": "Электронная почта:",
"Plan:": "План:",
"Amount:": "Сумма:",
"Subscription ID:": "ID подписки:",
"Go to Library": "Перейти в библиотеку",
"Need help? Contact our support team at support@readest.com": "Нужна помощь? Свяжитесь с нашей поддержкой: support@readest.com",
"Free Plan": "Бесплатный план",
@@ -643,5 +642,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Разблокировать дополнительные темы, шрифты, параметры макета и чтения вслух, переводчики, услуги облачного хранилища.",
"Purchase Successful!": "Покупка Успешна!",
"lifetime": "пожизненный",
"Thank you for your purchase! Your payment has been processed successfully.": "Спасибо за вашу покупку! Ваш платеж был успешно обработан."
"Thank you for your purchase! Your payment has been processed successfully.": "Спасибо за вашу покупку! Ваш платеж был успешно обработан.",
"Order ID:": "ID заказа:"
}
@@ -376,7 +376,6 @@
"Email:": "ඊමේල්:",
"Plan:": "සැලැස්ම:",
"Amount:": "මුදල:",
"Subscription ID:": "දායකත්ව ID:",
"Go to Library": "පුස්තකාලයට යන්න",
"Need help? Contact our support team at support@readest.com": "උදව් අවශ්‍යද? support@readest.com හි අපගේ සහාය කණ්ඩායමට සම්බන්ධ වන්න",
"Free Plan": "නොමිලේ සැලැස්ම",
@@ -635,5 +634,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "අමතර තේමාවන්, ෆොන්ට්, ආකෘති විකල්ප සහ පවසා කියවීම, පරිවර්තක, කලාප ගබඩා සේවා විවෘත කරන්න.",
"Purchase Successful!": "මිලදී ගැනීම සාර්ථකයි!",
"lifetime": "පොදුකාලීන",
"Thank you for your purchase! Your payment has been processed successfully.": "ඔබේ මිලදී ගැනීමට ස්තූතියි! ඔබේ ගෙවීම සාර්ථකව සැකසිණි."
"Thank you for your purchase! Your payment has been processed successfully.": "ඔබේ මිලදී ගැනීමට ස්තූතියි! ඔබේ ගෙවීම සාර්ථකව සැකසිණි.",
"Order ID:": "ඇණවුම් හැඳුනුම්පත:"
}
@@ -347,7 +347,6 @@
"Email:": "E-post:",
"Plan:": "Plan:",
"Amount:": "Belopp:",
"Subscription ID:": "Prenumerations-ID:",
"Need help? Contact our support team at support@readest.com": "Behöver du hjälp? Kontakta vår support på support@readest.com",
"Free Plan": "Gratisplan",
"month": "månad",
@@ -635,5 +634,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Lås upp ytterligare teman, typsnitt, layoutalternativ och uppläsning, översättare, molnlagringstjänster.",
"Purchase Successful!": "Köp lyckades!",
"lifetime": "livstid",
"Thank you for your purchase! Your payment has been processed successfully.": "Tack för ditt köp! Din betalning har behandlats."
"Thank you for your purchase! Your payment has been processed successfully.": "Tack för ditt köp! Din betalning har behandlats.",
"Order ID:": "Order-ID:"
}
@@ -376,7 +376,6 @@
"Email:": "மின்னஞ்சல்:",
"Plan:": "திட்டம்:",
"Amount:": "தொகை:",
"Subscription ID:": "சந்தா ID:",
"Go to Library": "நூலகத்திற்கு செல்லவும்",
"Need help? Contact our support team at support@readest.com": "உதவி தேவையா? support@readest.com இல் எங்கள் ஆதரவு குழுவைத் தொடர்பு கொள்ளவும்",
"Free Plan": "இலவச திட்டம்",
@@ -635,5 +634,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "கூடுதல் தீமைகள், எழுத்துருக்கள், வடிவமைப்பு விருப்பங்கள் மற்றும் வாசிக்க, மொழிபெயர்க்க, மேக சேமிப்பு சேவைகளைத் திறக்கவும்.",
"Purchase Successful!": "கொள்முதல் வெற்றிகரமாக முடிந்தது!",
"lifetime": "ஆயுள்திருத்தம்",
"Thank you for your purchase! Your payment has been processed successfully.": "உங்கள் வாங்குதலுக்கு நன்றி! உங்கள் கட்டணம் வெற்றிகரமாக செயல்படுத்தப்பட்டது."
"Thank you for your purchase! Your payment has been processed successfully.": "உங்கள் வாங்குதலுக்கு நன்றி! உங்கள் கட்டணம் வெற்றிகரமாக செயல்படுத்தப்பட்டது.",
"Order ID:": "ஆர்டர் ஐடி:"
}
@@ -312,7 +312,6 @@
"Email:": "อีเมล:",
"Plan:": "แผน:",
"Amount:": "จำนวนเงิน:",
"Subscription ID:": "รหัสสมาชิก:",
"Go to Library": "ไปที่คลังหนังสือ",
"Need help? Contact our support team at support@readest.com": "ต้องการความช่วยเหลือ? ติดต่อทีมสนับสนุนที่ support@readest.com",
"Free Plan": "แผนฟรี",
@@ -631,5 +630,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "ปลดล็อกธีมเพิ่มเติม ฟอนต์ ตัวเลือกการจัดรูปแบบ และฟีเจอร์การอ่านออกเสียง ตัวแปล และบริการพื้นที่เก็บข้อมูลคลาวด์",
"Purchase Successful!": "การซื้อสำเร็จ!",
"lifetime": "ตลอดชีพ",
"Thank you for your purchase! Your payment has been processed successfully.": "ขอบคุณสำหรับการซื้อ! การชำระเงินของคุณได้รับการประมวลผลเรียบร้อยแล้ว"
"Thank you for your purchase! Your payment has been processed successfully.": "ขอบคุณสำหรับการซื้อ! การชำระเงินของคุณได้รับการประมวลผลเรียบร้อยแล้ว",
"Order ID:": "รหัสคำสั่งซื้อ:"
}
@@ -363,7 +363,6 @@
"Email:": "E-posta:",
"Plan:": "Plan:",
"Amount:": "Tutar:",
"Subscription ID:": "Abonelik ID'si:",
"Go to Library": "Kütüphaneye Git",
"Need help? Contact our support team at support@readest.com": "Yardım mı gerekiyor? Destek ekibimizle iletişime geçin: support@readest.com",
"Free Plan": "Ücretsiz Plan",
@@ -635,5 +634,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Ek tema, yazı tipi, düzen seçeneklerini ve sesli okuma, çevirmenler, bulut depolama hizmetlerini açın.",
"Purchase Successful!": "Satın Alma Başarılı!",
"lifetime": "ömür boyu",
"Thank you for your purchase! Your payment has been processed successfully.": "Satın alma işleminiz için teşekkürler! Ödemeniz başarıyla işlendi."
"Thank you for your purchase! Your payment has been processed successfully.": "Satın alma işleminiz için teşekkürler! Ödemeniz başarıyla işlendi.",
"Order ID:": "Sipariş Kimliği:"
}
@@ -371,7 +371,6 @@
"Email:": "Електронна пошта:",
"Plan:": "План:",
"Amount:": "Сума:",
"Subscription ID:": "ID підписки:",
"Go to Library": "Перейти до бібліотеки",
"Need help? Contact our support team at support@readest.com": "Потрібна допомога? Зверніться до служби підтримки: support@readest.com",
"Free Plan": "Безкоштовний план",
@@ -643,5 +642,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Відкрийте додаткові теми, шрифти, параметри макета та функції читання вголос, перекладачі, послуги хмарного зберігання.",
"Purchase Successful!": "Покупка успішна!",
"lifetime": "довічний",
"Thank you for your purchase! Your payment has been processed successfully.": "Дякуємо за вашу покупку! Ваш платіж успішно оброблено."
"Thank you for your purchase! Your payment has been processed successfully.": "Дякуємо за вашу покупку! Ваш платіж успішно оброблено.",
"Order ID:": "ID замовлення:"
}
@@ -359,7 +359,6 @@
"Email:": "Email:",
"Plan:": "Gói:",
"Amount:": "Số tiền:",
"Subscription ID:": "ID đăng ký:",
"Go to Library": "Đi đến Thư viện",
"Need help? Contact our support team at support@readest.com": "Cần hỗ trợ? Liên hệ nhóm hỗ trợ tại support@readest.com",
"Free Plan": "Gói miễn phí",
@@ -631,5 +630,6 @@
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Mở khóa các chủ đề, phông chữ, tùy chọn bố cục và đọc to, dịch giả, dịch vụ lưu trữ đám mây bổ sung.",
"Purchase Successful!": "Mua hàng thành công!",
"lifetime": "trọn đời",
"Thank you for your purchase! Your payment has been processed successfully.": "Cảm ơn bạn đã mua hàng! Thanh toán của bạn đã được xử lý thành công."
"Thank you for your purchase! Your payment has been processed successfully.": "Cảm ơn bạn đã mua hàng! Thanh toán của bạn đã được xử lý thành công.",
"Order ID:": "Mã đơn hàng:"
}
@@ -359,7 +359,6 @@
"Email:": "邮箱:",
"Plan:": "套餐:",
"Amount:": "金额:",
"Subscription ID:": "订阅 ID",
"Go to Library": "前往书库",
"Need help? Contact our support team at support@readest.com": "需要帮助?请联系支持团队:support@readest.com",
"Free Plan": "免费套餐",
@@ -622,7 +621,7 @@
"Failed to open the book file": "无法打开书籍文件",
"On-Demand Purchase": "按需购买",
"Full Customization": "完全自定义",
"Lifetime Plan": "终身计划",
"Lifetime Plan": "终身套餐",
"One-Time Payment": "一次性付款",
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "进行一次性付款以享受在所有设备上对特定功能的终身访问。仅在需要时购买特定功能或服务。",
"Expand Cloud Sync Storage": "扩展云同步存储",
@@ -630,6 +629,7 @@
"Unlock All Customization Options": "解锁所有自定义选项",
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "解锁其他主题、字体、布局选项以及朗读、翻译、云存储服务。",
"Purchase Successful!": "购买成功!",
"lifetime": "终身",
"Thank you for your purchase! Your payment has been processed successfully.": "感谢您的购买!您的付款已成功处理。"
"lifetime": "永久",
"Thank you for your purchase! Your payment has been processed successfully.": "感谢您的购买!您的付款已成功处理。",
"Order ID:": "订单号:"
}
@@ -359,7 +359,6 @@
"Email:": "電子郵件:",
"Plan:": "方案:",
"Amount:": "金額:",
"Subscription ID:": "訂閱 ID",
"Go to Library": "前往書庫",
"Need help? Contact our support team at support@readest.com": "需要協助?請聯絡支援團隊:support@readest.com",
"Free Plan": "免費方案",
@@ -622,7 +621,7 @@
"Failed to open the book file": "無法打開書籍文件",
"On-Demand Purchase": "按需購買",
"Full Customization": "完全自定義",
"Lifetime Plan": "終身計劃",
"Lifetime Plan": "終身方案",
"One-Time Payment": "一次性付款",
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "進行一次性付款以享受在所有設備上對特定功能的終身訪問。僅在需要時購買特定功能或服務。",
"Expand Cloud Sync Storage": "擴展雲同步存儲",
@@ -630,6 +629,7 @@
"Unlock All Customization Options": "解鎖所有自定義選項",
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "解鎖其他主題、字體、佈局選項以及朗讀、翻譯、雲存儲服務。",
"Purchase Successful!": "購買成功!",
"lifetime": "終身",
"Thank you for your purchase! Your payment has been processed successfully.": "感謝您的購買!您的付款已成功處理。"
"lifetime": "永久",
"Thank you for your purchase! Your payment has been processed successfully.": "感謝您的購買!您的付款已成功處理。",
"Order ID:": "訂單編號:"
}
@@ -39,7 +39,7 @@ if grep -q 'MANAGE_EXTERNAL_STORAGE' "$MANIFEST"; then
fi
echo "🚀 Running: pnpm tauri android build"
pnpm tauri android build
pnpm tauri android build --config src-tauri/tauri.playstore.conf.json
# --- ADD PERMISSION BACK AFTER BUILD ---
if ! grep -q 'REQUEST_INSTALL_PACKAGES' "$MANIFEST"; then
+2 -3
View File
@@ -15,7 +15,7 @@
"security": {
"csp": {
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://translate.toil.cc https://*.microsofttranslator.com https://edge.microsoft.com https://*.googleusercontent.com",
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost http://*:* https://*:* https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://translate.toil.cc https://*.microsofttranslator.com https://edge.microsoft.com https://*.googleusercontent.com",
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://chinese-fonts-cdn.netlify.app https://cdnjs.cloudflare.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://chinese-fonts-cdn.netlify.app https://cdnjs.cloudflare.com",
@@ -64,8 +64,7 @@
}
},
"android": {
"minSdkVersion": 26,
"autoIncrementVersionCode": true
"minSdkVersion": 26
},
"iOS": {
"developmentTeam": "J5W48D69VR",
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { AppleIAPVerifier, createAppleIAPVerifier } from '@/libs/payment/apple/verifier';
import { AppleIAPVerifier, createAppleIAPVerifier } from '@/libs/payment/iap/apple/verifier';
const SKIP_IAP_API_TESTS = !process.env['ENABLE_IAP_API_TESTS'];
const REAL_TEST_DATA = {
@@ -1,104 +1,15 @@
import { z } from 'zod';
import { NextResponse } from 'next/server';
import { getAppleIAPVerifier } from '@/libs/payment/apple/verifier';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { IAPError } from '@/libs/payment/iap/types';
import { validateUserAndToken } from '@/utils/access';
import { IAPError } from '@/types/error';
import { getAppleIAPVerifier } from '@/libs/payment/iap/apple/verifier';
import { processPurchaseData, VerifiedPurchase } from '@/libs/payment/iap/apple/server';
const iapVerificationSchema = z.object({
transactionId: z.string().min(1, 'Transaction ID is required'),
originalTransactionId: z.string().min(1, 'Original Transaction ID is required'),
});
const PRODUCT_MAP: Record<string, string> = {
'com.bilingify.readest.monthly.plus': 'Plus',
'com.bilingify.readest.yearly.plus': 'Plus',
'com.bilingify.readest.monthly.pro': 'Pro',
'com.bilingify.readest.yearly.pro': 'Pro',
};
const getProductName = (productId: string) => {
return PRODUCT_MAP[productId] || productId;
};
const getProductPlan = (productId: string) => {
if (productId.includes('plus')) {
return 'plus';
} else if (productId.includes('pro')) {
return 'pro';
}
return 'free';
};
interface Purchase {
status: string;
customerEmail: string;
subscriptionId: string;
planName: string;
productId: string;
platform: string;
transactionId: string;
originalTransactionId: string;
purchaseDate?: string;
expiresDate?: string | null;
quantity: number;
environment: string;
bundleId: string;
webOrderLineItemId?: string;
subscriptionGroupIdentifier?: string;
type?: string;
revocationDate?: string | null;
revocationReason?: number | null;
}
async function updateUserSubscription(userId: string, purchase: Purchase) {
try {
const supabase = createSupabaseAdminClient();
const { data, error } = await supabase.from('apple_iap_subscriptions').upsert(
{
user_id: userId,
platform: purchase.platform,
product_id: purchase.productId,
transaction_id: purchase.transactionId,
original_transaction_id: purchase.originalTransactionId,
status: purchase.status === 'active' ? 'active' : 'expired',
purchase_date: purchase.purchaseDate,
expires_date: purchase.expiresDate,
environment: purchase.environment,
bundle_id: purchase.bundleId,
quantity: purchase.quantity || 1,
auto_renew_status: true,
web_order_line_item_id: purchase.webOrderLineItemId,
subscription_group_identifier: purchase.subscriptionGroupIdentifier,
created_at: new Date(),
updated_at: new Date(),
},
{
onConflict: 'user_id,original_transaction_id',
},
);
if (error) {
console.error('Database update error:', error);
throw new Error(`Database update failed: ${error.message}`);
}
const plan = await getProductPlan(purchase.productId);
await supabase
.from('plans')
.update({
plan: ['active', 'trialing'].includes(purchase.status) ? plan : 'free',
status: purchase.status,
})
.eq('id', userId);
return data;
} catch (error) {
console.error('Failed to update user subscription:', error);
throw error;
}
}
export async function POST(request: Request) {
const body = await request.json();
let validatedInput;
@@ -123,29 +34,13 @@ export async function POST(request: Request) {
}
try {
const supabase = createSupabaseAdminClient();
const { data: existingSubscription } = await supabase
.from('apple_iap_subscriptions')
.select('*')
.eq('original_transaction_id', originalTransactionId)
.single();
console.log('Existing subscription:', existingSubscription);
// Should not restore purchase for another account
if (existingSubscription && existingSubscription.user_id !== user.id) {
return NextResponse.json(
{ error: IAPError.TRANSACTION_BELONGS_TO_ANOTHER_USER },
{ status: 403 },
);
}
const defaultIAPVerifier = getAppleIAPVerifier();
const verificationResult = await defaultIAPVerifier.verifyTransaction(originalTransactionId);
if (!verificationResult.success) {
console.error('Apple verification failed:', verificationResult.error);
return NextResponse.json(
{
error: verificationResult.error || IAPError.UNKNOWN_ERROR,
error: verificationResult.error || IAPError.TRANSACTION_CANNOT_BE_VERIFIED,
purchase: null,
},
{ status: 400 },
@@ -158,37 +53,15 @@ export async function POST(request: Request) {
productId: transaction.productId,
environment: transaction.environment,
});
if (transaction.environment === 'Sandbox' && process.env.NODE_ENV === 'production') {
console.warn('Sandbox transaction in production environment');
}
const purchase = {
status: verificationResult.status!,
customerEmail: user.email!,
subscriptionId: transaction.webOrderLineItemId || transaction.originalTransactionId,
planName: getProductName(transaction.productId),
planType: verificationResult.planType,
productId: transaction.productId,
platform: 'ios',
transactionId: transaction.transactionId,
originalTransactionId: transaction.originalTransactionId,
purchaseDate: verificationResult.purchaseDate?.toISOString(),
expiresDate: verificationResult.expiresDate?.toISOString() || null,
quantity: transaction.quantity,
environment: transaction.environment.toLowerCase(),
bundleId: transaction.bundleId,
webOrderLineItemId: transaction.webOrderLineItemId,
subscriptionGroupIdentifier: transaction.subscriptionGroupIdentifier,
type: transaction.type,
revocationDate: verificationResult.revocationDate?.toISOString() || null,
revocationReason: verificationResult.revocationReason,
};
try {
await updateUserSubscription(user.id, purchase);
const purchase: VerifiedPurchase = await processPurchaseData(user, verificationResult);
return NextResponse.json({
purchase,
error: null,
});
} catch (dbError) {
console.error('Database update failed:', dbError);
return NextResponse.json(
{
error: IAPError.TRANSACTION_SERVICE_UNAVAILABLE,
@@ -197,11 +70,6 @@ export async function POST(request: Request) {
{ status: 500 },
);
}
return NextResponse.json({
purchase,
error: null,
});
} catch (error) {
console.error('IAP verification error:', error);
return NextResponse.json(
@@ -1,14 +1,9 @@
import { z } from 'zod';
import { NextResponse } from 'next/server';
import {
getGoogleIAPVerifier,
ProductPurchase,
SubscriptionPurchase,
} from '@/libs/payment/google/verifier';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { validateUserAndToken } from '@/utils/access';
import { IAPError } from '@/types/error';
import { PlanType } from '@/types/quota';
import { getGoogleIAPVerifier, VerifyPurchaseParams } from '@/libs/payment/iap/google/verifier';
import { processPurchaseData, VerifiedPurchase } from '@/libs/payment/iap/google/server';
import { IAPError } from '@/libs/payment/iap/types';
const iapVerificationSchema = z.object({
packageName: z.string().min(1, 'Package name is required'),
@@ -17,112 +12,6 @@ const iapVerificationSchema = z.object({
purchaseToken: z.string().min(1, 'Purchase token is required'),
});
const PRODUCT_MAP: Record<string, string> = {
'com.bilingify.readest.monthly.plus': 'Plus',
'com.bilingify.readest.yearly.plus': 'Plus',
'com.bilingify.readest.monthly.pro': 'Pro',
'com.bilingify.readest.yearly.pro': 'Pro',
};
const getProductName = (productId: string) => {
return PRODUCT_MAP[productId] || productId;
};
const getProductPlan = (productId: string) => {
if (productId.includes('plus')) {
return 'plus';
} else if (productId.includes('pro')) {
return 'pro';
}
return 'free';
};
interface Purchase {
status: string;
customerEmail: string;
subscriptionId: string;
planName: string;
planType: PlanType;
productId: string;
platform: string;
purchaseToken: string;
orderId: string;
purchaseDate?: string;
expiresDate?: string | null;
quantity: number;
environment: string;
packageName: string;
purchaseState?: number | null;
acknowledgementState?: number | null;
autoRenewing?: boolean | null;
priceAmountMicros?: string | null;
priceCurrencyCode?: string | null;
countryCode?: string | null;
developerPayload?: string | null;
linkedPurchaseToken?: string | null;
obfuscatedExternalAccountId?: string | null;
obfuscatedExternalProfileId?: string | null;
cancelReason?: number | null;
userCancellationTimeMillis?: string | null;
}
async function updateUserSubscription(userId: string, purchase: Purchase) {
try {
const supabase = createSupabaseAdminClient();
const { data, error } = await supabase.from('google_iap_subscriptions').upsert(
{
user_id: userId,
platform: purchase.platform,
product_id: purchase.productId,
purchase_token: purchase.purchaseToken,
order_id: purchase.orderId,
status: purchase.status === 'active' ? 'active' : 'expired',
purchase_date: purchase.purchaseDate,
expires_date: purchase.expiresDate,
environment: purchase.environment,
package_name: purchase.packageName,
quantity: purchase.quantity || 1,
auto_renew_status: purchase.autoRenewing || false,
purchase_state: purchase.purchaseState,
acknowledgement_state: purchase.acknowledgementState,
price_amount_micros: purchase.priceAmountMicros,
price_currency_code: purchase.priceCurrencyCode,
country_code: purchase.countryCode,
developer_payload: purchase.developerPayload,
linked_purchase_token: purchase.linkedPurchaseToken,
obfuscated_external_account_id: purchase.obfuscatedExternalAccountId,
obfuscated_external_profile_id: purchase.obfuscatedExternalProfileId,
cancel_reason: purchase.cancelReason,
user_cancellation_time_millis: purchase.userCancellationTimeMillis,
created_at: new Date(),
updated_at: new Date(),
},
{
onConflict: 'user_id,order_id',
},
);
if (error) {
console.error('Database update error:', error);
throw new Error(`Database update failed: ${error.message}`);
}
const plan = getProductPlan(purchase.productId);
await supabase
.from('plans')
.update({
plan: ['active', 'trialing'].includes(purchase.status) ? plan : 'free',
status: purchase.status,
})
.eq('id', userId);
return data;
} catch (error) {
console.error('Failed to update user subscription:', error);
throw error;
}
}
export async function POST(request: Request) {
const body = await request.json();
let validatedInput;
@@ -139,7 +28,7 @@ export async function POST(request: Request) {
);
}
}
const { purchaseToken, productId, packageName } = validatedInput!;
const { orderId, purchaseToken, productId, packageName } = validatedInput!;
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
if (!user || !token) {
@@ -147,39 +36,14 @@ export async function POST(request: Request) {
}
try {
const supabase = createSupabaseAdminClient();
// Check if this purchase already exists
if (purchaseToken) {
const { data: existingSubscription } = await supabase
.from('google_iap_subscriptions')
.select('*')
.eq('purchase_token', purchaseToken)
.single();
console.log('Existing subscription:', existingSubscription);
// Should not restore purchase for another account
if (existingSubscription && existingSubscription.user_id !== user.id) {
return NextResponse.json(
{ error: IAPError.TRANSACTION_BELONGS_TO_ANOTHER_USER },
{ status: 403 },
);
}
}
const googleIAPVerifier = getGoogleIAPVerifier();
const verificationResult = await googleIAPVerifier.verifyPurchase({
purchaseToken,
productId,
packageName,
});
const verifyParams: VerifyPurchaseParams = { orderId, purchaseToken, productId, packageName };
const verificationResult = await googleIAPVerifier.verifyPurchase(verifyParams);
if (!verificationResult.success) {
console.error('Google verification failed:', verificationResult.error);
return NextResponse.json(
{
error: verificationResult.error || IAPError.UNKNOWN_ERROR,
error: verificationResult.error || IAPError.TRANSACTION_CANNOT_BE_VERIFIED,
purchase: null,
},
{ status: 400 },
@@ -187,103 +51,29 @@ export async function POST(request: Request) {
}
const purchaseData = verificationResult.purchaseData!;
const isSubscription = verificationResult.purchaseType === 'subscription';
console.log('Google verification successful:', {
orderId: purchaseData.orderId,
productId: productId,
purchaseState: purchaseData.purchaseState,
});
// Check environment (test purchases have specific patterns in orderId)
const isTestPurchase = purchaseData.purchaseType === 0; // 0 = Test, 1 = Promo, undefined = Real
if (isTestPurchase && process.env.NODE_ENV === 'production') {
console.warn('Test purchase in production environment');
}
let purchase: Purchase;
if (isSubscription) {
const subData = purchaseData as SubscriptionPurchase;
purchase = {
status: verificationResult.status!,
customerEmail: user.email!,
subscriptionId: subData.orderId || purchaseToken,
planName: getProductName(productId),
planType: 'subscription',
productId: productId,
platform: 'android',
purchaseToken: purchaseToken,
orderId: subData.orderId || '',
purchaseDate: verificationResult.purchaseDate?.toISOString(),
expiresDate: verificationResult.expiresDate?.toISOString() || null,
quantity: subData.quantity || 1,
environment: isTestPurchase ? 'sandbox' : 'production',
packageName: packageName,
purchaseState: subData.purchaseState,
acknowledgementState: subData.acknowledgementState,
autoRenewing: subData.autoRenewing,
priceAmountMicros: subData.priceAmountMicros,
priceCurrencyCode: subData.priceCurrencyCode,
countryCode: subData.countryCode,
developerPayload: subData.developerPayload,
linkedPurchaseToken: subData.linkedPurchaseToken,
obfuscatedExternalAccountId: subData.obfuscatedExternalAccountId,
obfuscatedExternalProfileId: subData.obfuscatedExternalProfileId,
cancelReason: subData.cancelReason,
userCancellationTimeMillis: subData.userCancellationTimeMillis,
};
} else {
const prodData = purchaseData as ProductPurchase;
purchase = {
status: verificationResult.status!,
customerEmail: user.email!,
subscriptionId: prodData.orderId || purchaseToken,
planName: getProductName(productId),
planType: 'purchase',
productId: productId,
platform: 'android',
purchaseToken: purchaseToken,
orderId: prodData.orderId || '',
purchaseDate: verificationResult.purchaseDate?.toISOString(),
expiresDate: null, // One-time purchases don't expire
quantity: prodData.quantity || 1,
environment: isTestPurchase ? 'sandbox' : 'production',
packageName: packageName,
purchaseState: prodData.purchaseState,
acknowledgementState: prodData.acknowledgementState,
autoRenewing: false, // Not applicable for one-time purchases
priceAmountMicros: undefined,
priceCurrencyCode: prodData.regionCode,
countryCode: prodData.regionCode,
developerPayload: prodData.developerPayload,
linkedPurchaseToken: undefined,
obfuscatedExternalAccountId: prodData.obfuscatedExternalAccountId,
obfuscatedExternalProfileId: prodData.obfuscatedExternalProfileId,
cancelReason: null,
userCancellationTimeMillis: null,
};
}
// Acknowledge the purchase if needed
if (purchaseData.acknowledgementState === 0) {
try {
await googleIAPVerifier.acknowledgePurchase({
purchaseToken,
productId,
packageName,
});
purchase.acknowledgementState = 1;
} catch (ackError) {
console.error('Failed to acknowledge purchase:', ackError);
// Continue even if acknowledgement fails
}
}
let purchase: VerifiedPurchase;
try {
await updateUserSubscription(user.id, purchase);
purchase = await processPurchaseData(user, verifyParams, verificationResult);
if (verificationResult.purchaseData?.acknowledgementState === 0) {
try {
await googleIAPVerifier.acknowledgePurchase(verifyParams);
purchase.acknowledgementState = 1;
} catch (ackError) {
console.error('Failed to acknowledge purchase:', ackError);
}
}
return NextResponse.json({
purchase,
error: null,
});
} catch (dbError) {
console.error('Database update failed:', dbError);
return NextResponse.json(
{
error: IAPError.TRANSACTION_SERVICE_UNAVAILABLE,
@@ -292,11 +82,6 @@ export async function POST(request: Request) {
{ status: 500 },
);
}
return NextResponse.json({
purchase,
error: null,
});
} catch (error) {
console.error('IAP verification error:', error);
return NextResponse.json(
@@ -1,17 +1,14 @@
import { useTranslation } from '@/hooks/useTranslation';
import { UserPlan } from '@/types/quota';
import { PlanDetails } from '../utils/plan';
interface PlanNavigationProps {
allPlans: PlanDetails[];
currentPlanIndex: number;
onSelectPlan: (index: number) => void;
currentPlan: UserPlan;
onSelectPlan: (plan: UserPlan) => void;
}
const PlanNavigation: React.FC<PlanNavigationProps> = ({
allPlans,
currentPlanIndex,
onSelectPlan,
}) => {
const PlanNavigation: React.FC<PlanNavigationProps> = ({ allPlans, currentPlan, onSelectPlan }) => {
const _ = useTranslation();
return (
@@ -24,9 +21,9 @@ const PlanNavigation: React.FC<PlanNavigationProps> = ({
{allPlans.map((plan, index) => (
<button
key={`plan-${plan.plan}-${index}`}
onClick={() => onSelectPlan(index)}
onClick={() => onSelectPlan(plan.plan)}
className={`rounded-md px-2 py-2 text-sm font-medium shadow-sm transition-colors ${
currentPlanIndex === index ? `${plan.color}` : 'text-base-content hover:bg-base-200'
currentPlan === plan.plan ? `${plan.color}` : 'text-base-content hover:bg-base-200'
}`}
>
<span className='whitespace-nowrap'>{_(plan.name)}</span>
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { PlanType, UserPlan } from '@/types/quota';
import { AvailablePlan, PlanType, UserPlan } from '@/types/quota';
import { useEnv } from '@/context/EnvContext';
import { debounce } from '@/utils/debounce';
import { getPlanDetails } from '../utils/plan';
import { AvailablePlan } from '../page';
import PlanNavigation from './PlanNavigation';
import PlanCard from './PlanCard';
import PlanIndicators from './PlanIndicators';
@@ -18,12 +18,17 @@ const PlansComparison: React.FC<PlansComparisonProps> = ({
userPlan,
onSubscribe,
}) => {
const { appService } = useEnv();
const [currentPlanIndex, setCurrentPlanIndex] = useState(0);
const [userPlanIndex, setUserPlanIndex] = useState(0);
const plansScrollRef = useRef<HTMLDivElement>(null);
const userPlans: UserPlan[] = ['free', 'plus', 'pro', 'purchase'];
const allPlans = userPlans.map((plan) => ({
...getPlanDetails(plan, availablePlans),
}));
useEffect(() => {
if (userPlan) {
const initialPlanIndex = userPlans.indexOf(userPlan);
@@ -119,16 +124,19 @@ const PlansComparison: React.FC<PlansComparisonProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPlanIndex]);
const allPlans = userPlans.map((plan) => ({
...getPlanDetails(plan, availablePlans),
}));
const handleSelectPlan = (plan: UserPlan) => {
const index = userPlans.indexOf(plan);
if (index !== -1) {
setCurrentPlanIndex(index);
}
};
return (
<div className='bg-base-100 border-base-200 overflow-hidden rounded-xl border shadow-sm'>
<PlanNavigation
allPlans={allPlans}
currentPlanIndex={currentPlanIndex}
onSelectPlan={setCurrentPlanIndex}
allPlans={allPlans.filter((plan) => plan.plan !== 'free')}
currentPlan={userPlans[currentPlanIndex]!}
onSelectPlan={handleSelectPlan}
/>
<div
@@ -136,6 +144,7 @@ const PlansComparison: React.FC<PlansComparisonProps> = ({
className='plans-container scrollbar-hide flex items-start overflow-x-auto scroll-smooth sm:px-52'
onTouchStart={handleTouchStart}
style={{
scrollSnapType: appService?.isIOSApp ? 'x mandatory' : 'none',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
+86 -215
View File
@@ -1,8 +1,6 @@
'use client';
import clsx from 'clsx';
import Stripe from 'stripe';
import posthog from 'posthog-js';
import React, { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
@@ -11,19 +9,28 @@ import { useTheme } from '@/hooks/useTheme';
import { useThemeStore } from '@/store/themeStore';
import { useQuotaStats } from '@/hooks/useQuotaStats';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { PlanType, UserPlan } from '@/types/quota';
import { navigateToLibrary, navigateToResetPassword } from '@/utils/nav';
import { deleteUser } from '@/libs/user';
import { useUserActions } from '@/hooks/useUserActions';
import { useAvailablePlans } from '@/hooks/useAvailablePlans';
import { PlanType } from '@/types/quota';
import { navigateToLibrary } from '@/utils/nav';
import { eventDispatcher } from '@/utils/event';
import { getStripe } from '@/libs/payment/stripe/client';
import { getAPIBaseUrl, isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { openUrl } from '@tauri-apps/plugin-opener';
import { getAccessToken } from '@/utils/access';
import { IAPService, IAPProduct, IAPPurchase } from '@/utils/iap';
import { isTauriAppPlatform } from '@/services/environment';
import { getPlanDetails } from './utils/plan';
import { StripeProductMetadata } from '@/types/payment';
import { Toast } from '@/components/Toast';
import {
purchaseIAPProduct,
restoreIAPPurchases,
getSubscriptionSuccessUrl as getIAPSubscriptionSuccessUrl,
} from '@/libs/payment/iap/client';
import {
createStripeCheckoutSession,
redirectToStripeCheckout,
createStripePortalSession,
redirectToStripePortal,
handleStripeCheckoutError,
getSubscriptionSuccessUrl as getStripeSubscriptionSuccessUrl,
StripeAvailablePlan,
} from '@/libs/payment/stripe/client';
import LegalLinks from '@/components/LegalLinks';
import Spinner from '@/components/Spinner';
import ProfileHeader from './components/Header';
@@ -32,24 +39,7 @@ import UsageStats from './components/UsageStats';
import PlansComparison from './components/PlansComparison';
import AccountActions from './components/AccountActions';
import Checkout from './components/Checkout';
const WEB_STRIPE_PLANS_URL = `${getAPIBaseUrl()}/stripe/plans`;
const WEB_STRIPE_CHECKOUT_URL = `${getAPIBaseUrl()}/stripe/checkout`;
const WEB_STRIPE_PORTAL_URL = `${getAPIBaseUrl()}/stripe/portal`;
const SUBSCRIPTION_SUCCESS_PATH = '/user/subscription/success';
export type PlanInterval = 'month' | 'year' | 'lifetime';
export type AvailablePlan = {
plan: UserPlan;
productId: string;
price: number; // in cents
currency: string;
interval: PlanInterval;
productName: string;
metadata?: StripeProductMetadata;
product?: Stripe.Product;
};
import { isPurchaseProduct } from '@/libs/payment/iap/utils';
type CheckoutState = {
clientSecret: string;
@@ -60,13 +50,11 @@ type CheckoutState = {
const ProfilePage = () => {
const _ = useTranslation();
const router = useRouter();
const { envConfig, appService } = useEnv();
const { token, user, logout } = useAuth();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { appService } = useEnv();
const { token, user } = useAuth();
const { safeAreaInsets, isRoundedWindow } = useThemeStore();
const [loading, setLoading] = useState(false);
const [availablePlans, setAvailablePlans] = useState<AvailablePlan[]>([]);
const [showEmbeddedCheckout, setShowEmbeddedCheckout] = useState(false);
const [checkoutState, setCheckoutState] = useState<CheckoutState>({
clientSecret: '',
@@ -80,6 +68,20 @@ const ProfilePage = () => {
useTheme({ systemUIVisible: false });
const { quotas, userPlan = 'free' } = useQuotaStats();
const { handleLogout, handleResetPassword, handleConfirmDelete } = useUserActions();
const { availablePlans } = useAvailablePlans({
hasIAP: appService?.hasIAP || false,
onError: useCallback(
(message: string) => {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _(message),
});
},
[_],
),
});
const handleGoBack = () => {
if (showEmbeddedCheckout) {
@@ -89,147 +91,78 @@ const ProfilePage = () => {
}
};
const handleLogout = () => {
logout();
settings.keepLogin = false;
setSettings(settings);
saveSettings(envConfig, settings);
navigateToLibrary(router);
};
const handleResetPassword = () => {
navigateToResetPassword(router);
};
const handleConfirmDelete = async () => {
try {
await deleteUser();
handleLogout();
} catch (error) {
console.error('Error deleting user:', error);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to delete user. Please try again later.'),
});
}
};
const handleStripeSubscribe = async (productId?: string, planType: PlanType = 'subscription') => {
const token = await getAccessToken();
const stripe = await getStripe();
if (!stripe) {
console.error('Stripe not loaded');
return;
}
if (!productId) return;
setLoading(true);
const isEmbeddedCheckout = isTauriAppPlatform();
const response = await fetch(WEB_STRIPE_CHECKOUT_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ priceId: productId, planType, embedded: isEmbeddedCheckout }),
});
setLoading(false);
if (!response.ok) {
console.error('Failed to create Stripe checkout session');
posthog.capture('checkout_error', {
error: 'Failed to create Stripe checkout session',
});
try {
const { sessionId, clientSecret, url } = await createStripeCheckoutSession(
productId,
planType,
);
const selectedPlan = availablePlans.find(
(plan) => plan.productId === productId,
)! as StripeAvailablePlan;
const planName = selectedPlan.product?.name || selectedPlan.productName;
const isEmbeddedCheckout = isTauriAppPlatform();
if (isEmbeddedCheckout && sessionId && clientSecret) {
setShowEmbeddedCheckout(true);
setCheckoutState({
planName,
clientSecret,
sessionId,
});
} else {
await redirectToStripeCheckout(sessionId, url);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
handleStripeCheckoutError(errorMessage);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to create checkout session'),
});
return;
}
const { sessionId, clientSecret, url } = await response.json();
const selectedPlan = availablePlans.find((plan) => plan.productId === productId)!;
const planName = selectedPlan.product?.name || selectedPlan.productName;
if (isEmbeddedCheckout && sessionId && clientSecret) {
setShowEmbeddedCheckout(true);
setCheckoutState({
planName,
clientSecret,
sessionId,
});
} else if (url) {
if (isWebAppPlatform()) {
window.location.href = url;
} else if (isTauriAppPlatform()) {
await openUrl(url);
}
} else if (sessionId) {
const result = await stripe.redirectToCheckout({ sessionId });
if (result.error) {
console.error(result.error);
posthog.capture('checkout_error', {
error: 'Failed to redirect to checkout',
});
}
} else {
console.error('No sessionId or url returned from checkout API');
posthog.capture('checkout_error', {
error: 'No sessionId or url returned from checkout API',
});
} finally {
setLoading(false);
}
};
const handleCheckoutSuccess = useCallback(
(sessionId: string) => {
setShowEmbeddedCheckout(false);
const params = new URLSearchParams({
payment: 'stripe',
session_id: sessionId,
});
router.push(`${SUBSCRIPTION_SUCCESS_PATH}?${params.toString()}`);
router.push(getStripeSubscriptionSuccessUrl(sessionId));
},
[router],
);
const getPuchaseVerifyParams = (purchase: IAPPurchase) => {
return new URLSearchParams({
payment: 'iap',
platform: purchase.platform,
product_id: purchase.productId,
transaction_id: purchase.transactionId || '',
original_transaction_id: purchase.originalTransactionId || '',
package_name: purchase.packageName || '',
order_id: purchase.orderId || '',
purchase_token: purchase.purchaseToken || '',
});
};
const handleIAPSubscribe = async (productId?: string) => {
if (!productId) return;
setLoading(true);
const iapService = new IAPService();
try {
const purchase = await iapService.purchaseProduct(productId);
const purchase = await purchaseIAPProduct(productId);
if (purchase) {
const params = getPuchaseVerifyParams(purchase);
router.push(`${SUBSCRIPTION_SUCCESS_PATH}?${params.toString()}`);
router.push(getIAPSubscriptionSuccessUrl(purchase));
}
} catch (error) {
console.error('IAP purchase error:', error);
} finally {
setLoading(false);
}
setLoading(false);
};
const handleIAPRestorePurchase = async () => {
setLoading(true);
const iapService = new IAPService();
try {
const purchases = await iapService.restorePurchases();
const purchases = await restoreIAPPurchases();
if (purchases.length > 0) {
purchases.sort(
(a, b) => new Date(b.purchaseDate).getTime() - new Date(a.purchaseDate).getTime(),
);
purchases
.filter((p) => !isPurchaseProduct(p.productId))
.sort((a, b) => new Date(b.purchaseDate).getTime() - new Date(a.purchaseDate).getTime());
const purchase = purchases[0]!;
const params = getPuchaseVerifyParams(purchase);
router.push(`${SUBSCRIPTION_SUCCESS_PATH}?${params.toString()}`);
router.push(getIAPSubscriptionSuccessUrl(purchase));
} else {
eventDispatcher.dispatch('toast', {
type: 'info',
@@ -248,85 +181,23 @@ const ProfilePage = () => {
const handleManageSubscription = async () => {
setLoading(true);
const token = await getAccessToken();
const response = await fetch(WEB_STRIPE_PORTAL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
setLoading(false);
const { url, error } = await response.json();
if (error) {
try {
const url = await createStripePortalSession();
await redirectToStripePortal(url);
} catch (error) {
console.error('Error creating portal session:', error);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to manage subscription.'),
});
return;
}
if (isWebAppPlatform()) {
window.location.href = url;
} else if (isTauriAppPlatform()) {
await openUrl(url);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!appService) return;
if (appService?.hasIAP) {
const iapService = new IAPService();
iapService
.initialize()
.then(() =>
iapService.fetchProducts([
'com.bilingify.readest.monthly.plus',
'com.bilingify.readest.monthly.pro',
]),
)
.then((products: IAPProduct[]) => {
const availablePlans: AvailablePlan[] = products.map((product) => ({
plan: product.id.includes('plus')
? 'plus'
: product.id.includes('pro')
? 'pro'
: product.id.includes('purchase')
? 'purchase'
: 'free',
productId: product.id,
price: product.priceAmountMicros / 10000,
currency: product.priceCurrencyCode || 'USD',
interval: product.id.includes('monthly')
? 'month'
: product.id.includes('yearly')
? 'year'
: 'lifetime',
productName: product.title,
}));
setAvailablePlans(availablePlans);
})
.catch((error) => {
console.error('Failed to fetch IAP products:', error);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to load subscription plans.'),
});
});
} else {
fetch(WEB_STRIPE_PLANS_URL)
.then((res) => res.json())
.then((data) => {
const availablePlans = data && data instanceof Array ? data : [];
setAvailablePlans(availablePlans);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService]);
const handleDeleteWithMessage = () => {
handleConfirmDelete(_('Failed to delete user. Please try again later.'));
};
if (!mounted) {
return null;
@@ -367,7 +238,7 @@ const ProfilePage = () => {
<div className='w-full min-w-60 max-w-4xl py-10'>
{loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
<Spinner loading className='text-gray-900' />
</div>
)}
{showEmbeddedCheckout ? (
@@ -406,7 +277,7 @@ const ProfilePage = () => {
userPlan={userPlan}
onLogout={handleLogout}
onResetPassword={handleResetPassword}
onConfirmDelete={handleConfirmDelete}
onConfirmDelete={handleDeleteWithMessage}
onRestorePurchase={handleIAPRestorePurchase}
onManageSubscription={handleManageSubscription}
/>
@@ -8,18 +8,19 @@ import { getAccessToken } from '@/utils/access';
import { supabase } from '@/utils/supabase';
import { PlanType } from '@/types/quota';
import Spinner from '@/components/Spinner';
import { VerifiedIAP } from '@/libs/payment/iap/types';
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';
status: 'loading' | 'completed' | 'failed' | 'processing';
customerEmail: string;
subscriptionId?: string;
orderId?: string;
planName?: string;
planType: PlanType;
amount?: number;
amount?: number; // in cents
currency?: string;
}
@@ -76,9 +77,9 @@ const SuccessPageWithSearchParams = () => {
const session = stripeCheckoutSession as Stripe.Checkout.Session;
setSessionStatus({
status: session.payment_status === 'paid' ? 'complete' : 'failed',
status: session.payment_status === 'paid' ? 'completed' : 'failed',
customerEmail: session.customer_email || session.customer_details?.email || '',
subscriptionId: (session.subscription || session.payment_intent || '') as string,
orderId: (session.subscription || session.payment_intent || '') as string,
planName: session.line_items?.data?.[0]?.description || '',
planType: session.mode === 'payment' ? 'purchase' : 'subscription',
amount: session.amount_total || undefined,
@@ -120,7 +121,7 @@ const SuccessPageWithSearchParams = () => {
throw new Error(`HTTP error! status: ${response.status}`);
}
const { purchase, error } = await response.json();
const { purchase: iOSPurchase, error } = await response.json();
if (error) {
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
@@ -128,10 +129,11 @@ const SuccessPageWithSearchParams = () => {
return;
}
const purchase = iOSPurchase as VerifiedIAP;
setSessionStatus({
status: purchase.status === 'active' ? 'complete' : 'failed',
status: purchase.status === 'active' ? 'completed' : 'failed',
customerEmail: purchase.customerEmail || '',
subscriptionId: purchase.subscriptionId,
orderId: purchase.orderId,
planName: purchase.planName,
planType: purchase.planType,
});
@@ -177,7 +179,7 @@ const SuccessPageWithSearchParams = () => {
throw new Error(`HTTP error! status: ${response.status}`);
}
const { purchase, error } = await response.json();
const { purchase: androidPurchase, error } = await response.json();
if (error) {
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
@@ -185,16 +187,15 @@ const SuccessPageWithSearchParams = () => {
return;
}
const purchase = androidPurchase as VerifiedIAP;
setSessionStatus({
status: purchase.status === 'active' ? 'complete' : 'failed',
status: purchase.status === 'active' ? 'completed' : 'failed',
customerEmail: purchase.customerEmail || '',
subscriptionId: purchase.subscriptionId || purchase.orderId,
orderId: purchase.orderId,
planName: purchase.planName,
planType: purchase.planType,
amount: purchase.priceAmountMicros
? Number(purchase.priceAmountMicros) / 1000000
: undefined,
currency: purchase.priceCurrencyCode,
amount: purchase.amount,
currency: purchase.currency,
});
try {
@@ -409,10 +410,10 @@ const SuccessPageWithSearchParams = () => {
</span>
</div>
)}
{sessionStatus.subscriptionId && (
{sessionStatus.orderId && (
<div className='flex justify-between'>
<span className='font-medium'>{_('Subscription ID:')}</span>
<span className='font-mono text-xs'>{sessionStatus.subscriptionId}</span>
<span className='font-medium'>{_('Order ID:')}</span>
<span className='font-mono text-xs'>{sessionStatus.orderId}</span>
</div>
)}
</div>
+3 -3
View File
@@ -1,6 +1,6 @@
import { PlanType, QuotaFeature, UserPlan } from '@/types/quota';
import { StripeAvailablePlan } from '@/libs/payment/stripe/client';
import { AvailablePlan, PlanInterval, PlanType, QuotaFeature, UserPlan } from '@/types/quota';
import { stubTranslation as _ } from '@/utils/misc';
import { AvailablePlan, PlanInterval } from '../page';
type FeatureType = {
label: string;
@@ -44,7 +44,7 @@ const getProductFeature = (productId: string): QuotaFeature | undefined => {
export const getPlanDetails = (
planCode: UserPlan,
availablePlans: AvailablePlan[],
availablePlans: (AvailablePlan & StripeAvailablePlan)[],
interval: PlanInterval = 'month',
): PlanDetails => {
const availablePlan = availablePlans.find(
+3 -2
View File
@@ -5,7 +5,8 @@ import { useTranslation } from '@/hooks/useTranslation';
const Spinner: React.FC<{
loading: boolean;
}> = ({ loading }) => {
className?: string;
}> = ({ loading, className }) => {
const _ = useTranslation();
const { safeAreaInsets } = useThemeStore();
if (!loading) return null;
@@ -18,7 +19,7 @@ const Spinner: React.FC<{
}}
role='status'
>
<span className='loading loading-dots loading-lg'></span>
<span className={clsx('loading loading-dots loading-lg', className)}></span>
<span className='hidden'>{_('Loading...')}</span>
</div>
);
@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react';
import { fetchAndTransformIAPPlans } from '@/libs/payment/iap/client';
import { fetchStripePlans } from '@/libs/payment/stripe/client';
import { AvailablePlan } from '@/types/quota';
import { stubTranslation as _ } from '@/utils/misc';
const IAP_PRODUCT_IDS = [
'com.bilingify.readest.monthly.plus',
'com.bilingify.readest.monthly.pro',
'com.bilingify.readest.storage.1gb.purchase',
'com.bilingify.readest.storage.2gb.purchase',
'com.bilingify.readest.storage.5gb.purchase',
'com.bilingify.readest.storage.10gb.purchase',
];
interface UseAvailablePlansParams {
hasIAP: boolean;
onError?: (message: string) => void;
}
export const useAvailablePlans = ({ hasIAP, onError }: UseAvailablePlansParams) => {
const [availablePlans, setAvailablePlans] = useState<AvailablePlan[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchPlans = async () => {
setLoading(true);
setError(null);
try {
if (hasIAP) {
const plans = await fetchAndTransformIAPPlans(IAP_PRODUCT_IDS);
setAvailablePlans(plans);
} else {
const plans = await fetchStripePlans();
setAvailablePlans(plans);
}
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
setError(error);
console.error(`Failed to fetch ${hasIAP ? 'IAP' : 'Stripe'} plans:`, error);
if (onError) {
onError(_('Failed to load subscription plans.'));
}
} finally {
setLoading(false);
}
};
fetchPlans();
}, [hasIAP, onError]);
return { availablePlans, loading, error };
};
+2 -1
View File
@@ -1,4 +1,5 @@
import '@/i18n/i18n';
import { useCallback } from 'react';
import { useTranslation as _useTranslation } from 'react-i18next';
export type TranslationFunc = (key: string, options?: Record<string, number | string>) => string;
@@ -6,5 +7,5 @@ export type TranslationFunc = (key: string, options?: Record<string, number | st
export const useTranslation = (namespace: string = 'translation') => {
const { t } = _useTranslation(namespace);
return (key: string, options = {}) => t(key, { defaultValue: key, ...options });
return useCallback((key: string, options = {}) => t(key, { defaultValue: key, ...options }), [t]);
};
@@ -0,0 +1,42 @@
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { deleteUser } from '@/libs/user';
import { eventDispatcher } from '@/utils/event';
import { saveSysSettings } from '@/helpers/settings';
import { navigateToLibrary, navigateToResetPassword } from '@/utils/nav';
export const useUserActions = () => {
const router = useRouter();
const { envConfig } = useEnv();
const { logout } = useAuth();
const handleLogout = () => {
logout();
saveSysSettings(envConfig, 'keepLogin', false);
navigateToLibrary(router);
};
const handleResetPassword = () => {
navigateToResetPassword(router);
};
const handleConfirmDelete = async (errorMessage: string) => {
try {
await deleteUser();
handleLogout();
} catch (error) {
console.error('Error deleting user:', error);
eventDispatcher.dispatch('toast', {
type: 'error',
message: errorMessage,
});
}
};
return {
handleLogout,
handleResetPassword,
handleConfirmDelete,
};
};
@@ -0,0 +1,167 @@
import { ApplePaymentData } from '@/types/payment';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { updateUserStorage } from '@/libs/payment/storage';
import {
isStoragePurchase,
mapProductIdToProductName,
mapProductIdToUserPlan,
parseStorageGB,
} from '../utils';
import { IAPError, VerifiedIAP } from '../types';
import { VerificationResult } from './verifier';
export type VerifiedPurchase = VerifiedIAP & {
transactionId: string;
originalTransactionId: string;
purchaseDate?: string;
expiresDate?: string | null;
quantity: number;
environment: string;
bundleId: string;
webOrderLineItemId?: string;
subscriptionGroupIdentifier?: string;
type?: string;
revocationDate?: string | null;
revocationReason?: number | null;
};
export async function createOrUpdateSubscription(userId: string, purchase: VerifiedPurchase) {
try {
const supabase = createSupabaseAdminClient();
const { data: existingSubscription } = await supabase
.from('apple_iap_subscriptions')
.select('*')
.eq('original_transaction_id', purchase.originalTransactionId)
.single();
if (existingSubscription && existingSubscription.user_id !== userId) {
throw new Error(IAPError.TRANSACTION_BELONGS_TO_ANOTHER_USER);
}
const { data, error } = await supabase.from('apple_iap_subscriptions').upsert(
{
user_id: userId,
platform: purchase.platform,
product_id: purchase.productId,
transaction_id: purchase.transactionId,
original_transaction_id: purchase.originalTransactionId,
status: purchase.status === 'active' ? 'active' : 'expired',
purchase_date: purchase.purchaseDate,
expires_date: purchase.expiresDate,
environment: purchase.environment,
bundle_id: purchase.bundleId,
quantity: purchase.quantity || 1,
auto_renew_status: true,
web_order_line_item_id: purchase.webOrderLineItemId,
subscription_group_identifier: purchase.subscriptionGroupIdentifier,
created_at: new Date(),
updated_at: new Date(),
},
{
onConflict: 'user_id,original_transaction_id',
},
);
if (error) {
console.error('Database update error:', error);
throw new Error(`Database update failed: ${error.message}`);
}
const plan = mapProductIdToUserPlan(purchase.productId, true);
await supabase
.from('plans')
.update({
plan: ['active', 'trialing'].includes(purchase.status) ? plan : 'free',
status: purchase.status,
})
.eq('id', userId);
return data;
} catch (error) {
console.error('Failed to update user subscription:', error);
throw error;
}
}
export async function createOrUpdatePayment(userId: string, purchase: VerifiedPurchase) {
try {
const supabase = createSupabaseAdminClient();
const existingPayment = await supabase
.from('payments')
.select('*')
.eq('apple_original_transaction_id', purchase.originalTransactionId)
.single();
if (existingPayment.data && existingPayment.data.user_id !== userId) {
throw new Error(IAPError.TRANSACTION_BELONGS_TO_ANOTHER_USER);
}
const paymentData: ApplePaymentData = {
user_id: userId,
provider: 'apple',
product_id: purchase.productId,
apple_transaction_id: purchase.transactionId,
apple_original_transaction_id: purchase.originalTransactionId,
storage_gb: isStoragePurchase(purchase.productId) ? parseStorageGB(purchase.productId) : 0,
status: purchase.status === 'active' ? 'completed' : 'failed',
amount: purchase.amount,
currency: purchase.currency,
};
const { data, error } = await supabase.from('payments').upsert(paymentData, {
onConflict: 'apple_original_transaction_id',
ignoreDuplicates: false,
});
if (error) {
console.error('Database payment update error:', error);
throw new Error(`Database payment update failed: ${error.message}`);
}
await updateUserStorage(userId);
return data;
} catch (error) {
console.error('Failed to update user payment:', error);
throw error;
}
}
export async function processPurchaseData(
user: { id: string; email?: string | undefined },
verificationResult: VerificationResult,
): Promise<VerifiedPurchase> {
const transaction = verificationResult.transaction!;
if (transaction.environment === 'Sandbox' && process.env.NODE_ENV === 'production') {
console.warn('Sandbox transaction in production environment');
}
const purchase: VerifiedPurchase = {
status: verificationResult.status!,
customerEmail: user.email!,
orderId: transaction.webOrderLineItemId || transaction.originalTransactionId,
subscriptionId: transaction.webOrderLineItemId || transaction.originalTransactionId,
planName: mapProductIdToProductName(transaction.productId),
planType: verificationResult.planType!,
productId: transaction.productId,
platform: 'ios',
transactionId: transaction.transactionId,
originalTransactionId: transaction.originalTransactionId,
purchaseDate: verificationResult.purchaseDate?.toISOString(),
expiresDate: verificationResult.expiresDate?.toISOString() || null,
quantity: transaction.quantity,
environment: transaction.environment.toLowerCase(),
bundleId: transaction.bundleId,
webOrderLineItemId: transaction.webOrderLineItemId,
subscriptionGroupIdentifier: transaction.subscriptionGroupIdentifier,
type: transaction.type,
revocationDate: verificationResult.revocationDate?.toISOString() || null,
revocationReason: verificationResult.revocationReason,
};
if (purchase.planType === 'subscription') {
await createOrUpdateSubscription(user.id, purchase);
} else if (purchase.planType === 'purchase') {
await createOrUpdatePayment(user.id, purchase);
}
return purchase;
}
@@ -1,4 +1,3 @@
import { IAPError } from '@/types/error';
import { PlanType } from '@/types/quota';
import {
AppStoreServerAPI,
@@ -7,6 +6,7 @@ import {
TransactionType,
decodeTransaction,
} from 'app-store-server-api';
import { IAPError, IAPStatus } from '../types';
export interface AppleIAPConfig {
keyId: string;
@@ -19,7 +19,7 @@ export interface AppleIAPConfig {
export interface VerificationResult {
success: boolean;
verified?: boolean;
status?: string;
status?: IAPStatus;
planType?: PlanType;
transaction?: JWSTransactionDecodedPayload;
environment?: string;
@@ -56,47 +56,66 @@ export class AppleIAPVerifier {
}
async verifyTransaction(originalTransactionId: string): Promise<VerificationResult> {
let response;
try {
response = await this.client.getSubscriptionStatuses(originalTransactionId);
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : IAPError.UNKNOWN_ERROR,
};
}
if (response.data && response.data.length > 0) {
const transaction = response.data[0]!;
const lastTransaction = transaction.lastTransactions.find(
(item) => item.originalTransactionId === originalTransactionId,
);
if (!lastTransaction) {
const transactionResponse = await this.client.getTransactionInfo(originalTransactionId);
if (!transactionResponse?.signedTransactionInfo) {
return {
success: false,
error: IAPError.TRANSACTION_NOT_FOUND,
};
}
const decodedTransaction = await decodeTransaction(lastTransaction.signedTransactionInfo);
const status = lastTransaction.status;
const expiresDate = decodedTransaction.expiresDate
? new Date(decodedTransaction.expiresDate)
: undefined;
const now = new Date();
// Status 1 = Active subscription
const isActive = status === 1 && (!expiresDate || expiresDate > now);
let decodedTransaction = await decodeTransaction(transactionResponse.signedTransactionInfo);
const planType: PlanType =
decodedTransaction.type === TransactionType.NonConsumable ||
decodedTransaction.type === TransactionType.Consumable
? 'purchase'
: 'subscription';
let status: IAPStatus = 'active';
let expiresDate: Date | null = null;
if (planType === 'subscription') {
try {
const subscriptionResponse =
await this.client.getSubscriptionStatuses(originalTransactionId);
if (subscriptionResponse.data && subscriptionResponse.data.length > 0) {
const transaction = subscriptionResponse.data[0]!;
const lastTransaction = transaction.lastTransactions.find(
(item) => item.originalTransactionId === originalTransactionId,
);
if (lastTransaction) {
decodedTransaction = await decodeTransaction(lastTransaction.signedTransactionInfo);
expiresDate = decodedTransaction.expiresDate
? new Date(decodedTransaction.expiresDate)
: null;
const now = new Date();
// Status 1 = Active subscription
status =
lastTransaction.status === 1 && (!expiresDate || expiresDate > now)
? 'active'
: 'expired';
}
}
} catch (error) {
console.warn('Failed to get subscription status:', error);
}
} else {
if (decodedTransaction.revocationDate) {
status = 'expired';
} else {
status = 'active';
}
expiresDate = null;
}
return {
success: true,
verified: true,
status: isActive ? 'active' : 'expired',
status: status,
planType: planType,
transaction: decodedTransaction,
environment: this.environment,
@@ -105,9 +124,7 @@ export class AppleIAPVerifier {
transactionId: decodedTransaction.transactionId,
originalTransactionId: decodedTransaction.originalTransactionId,
purchaseDate: new Date(decodedTransaction.purchaseDate),
expiresDate: decodedTransaction.expiresDate
? new Date(decodedTransaction.expiresDate)
: null,
expiresDate: expiresDate,
quantity: decodedTransaction.quantity,
type: decodedTransaction.type,
revocationDate: decodedTransaction.revocationDate
@@ -117,10 +134,10 @@ export class AppleIAPVerifier {
webOrderLineItemId: decodedTransaction.webOrderLineItemId,
subscriptionGroupIdentifier: decodedTransaction.subscriptionGroupIdentifier,
};
} else {
} catch (error) {
return {
success: false,
error: IAPError.TRANSACTION_NOT_FOUND,
error: error instanceof Error ? error.message : IAPError.UNKNOWN_ERROR,
};
}
}
@@ -0,0 +1,64 @@
import { AvailablePlan } from '@/types/quota';
import { IAPService, IAPPurchase, IAPProduct } from '@/utils/iap';
import { mapProductIdToInterval, mapProductIdToUserPlan } from './utils';
const SUBSCRIPTION_SUCCESS_PATH = '/user/subscription/success';
export const getPurchaseVerifyParams = (purchase: IAPPurchase) => {
return new URLSearchParams({
payment: 'iap',
platform: purchase.platform,
product_id: purchase.productId,
transaction_id: purchase.transactionId || '',
original_transaction_id: purchase.originalTransactionId || '',
package_name: purchase.packageName || '',
order_id: purchase.orderId || '',
purchase_token: purchase.purchaseToken || '',
});
};
export const purchaseIAPProduct = async (productId: string) => {
const iapService = new IAPService();
const purchase = await iapService.purchaseProduct(productId);
return purchase;
};
export const restoreIAPPurchases = async () => {
const iapService = new IAPService();
const purchases = await iapService.restorePurchases();
return purchases;
};
export const initializeIAP = async () => {
const iapService = new IAPService();
await iapService.initialize();
return iapService;
};
export const fetchIAPProducts = async (productIds: string[]) => {
const iapService = new IAPService();
await iapService.initialize();
const products = await iapService.fetchProducts(productIds);
return products;
};
export const transformIAPProductToAvailablePlan = (product: IAPProduct): AvailablePlan => {
return {
plan: mapProductIdToUserPlan(product.id),
productId: product.id,
price: product.priceAmountMicros / 10000,
currency: product.priceCurrencyCode || 'USD',
interval: mapProductIdToInterval(product.id),
productName: product.title,
};
};
export const fetchAndTransformIAPPlans = async (productIds: string[]): Promise<AvailablePlan[]> => {
const products = await fetchIAPProducts(productIds);
return products.map(transformIAPProductToAvailablePlan);
};
export const getSubscriptionSuccessUrl = (purchase: IAPPurchase) => {
const params = getPurchaseVerifyParams(purchase);
return `${SUBSCRIPTION_SUCCESS_PATH}?${params.toString()}`;
};
@@ -0,0 +1,235 @@
import { GooglePaymentData } from '@/types/payment';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { updateUserStorage } from '@/libs/payment/storage';
import {
isStoragePurchase,
mapProductIdToProductName,
mapProductIdToUserPlan,
parseStorageGB,
} from '../utils';
import { IAPError, VerifiedIAP } from '../types';
import {
ProductPurchase,
SubscriptionPurchase,
VerificationResult,
VerifyPurchaseParams,
} from './verifier';
export type VerifiedPurchase = VerifiedIAP & {
purchaseToken: string;
purchaseDate?: string;
expiresDate?: string | null;
quantity: number;
environment: string;
packageName: string;
purchaseState?: number | null;
acknowledgementState?: number | null;
autoRenewing?: boolean | null;
priceAmountMicros?: string | null;
priceCurrencyCode?: string | null;
countryCode?: string | null;
developerPayload?: string | null;
linkedPurchaseToken?: string | null;
obfuscatedExternalAccountId?: string | null;
obfuscatedExternalProfileId?: string | null;
cancelReason?: number | null;
userCancellationTimeMillis?: string | null;
};
export async function createOrUpdateSubscription(userId: string, purchase: VerifiedPurchase) {
try {
const supabase = createSupabaseAdminClient();
const { data: existingSubscription } = await supabase
.from('google_iap_subscriptions')
.select('*')
.eq('purchase_token', purchase.purchaseToken)
.single();
if (existingSubscription && existingSubscription.user_id !== userId) {
throw new Error(IAPError.TRANSACTION_BELONGS_TO_ANOTHER_USER);
}
const { data, error } = await supabase.from('google_iap_subscriptions').upsert(
{
user_id: userId,
platform: purchase.platform,
product_id: purchase.productId,
purchase_token: purchase.purchaseToken,
order_id: purchase.orderId,
status: purchase.status === 'active' ? 'active' : 'expired',
purchase_date: purchase.purchaseDate,
expires_date: purchase.expiresDate,
environment: purchase.environment,
package_name: purchase.packageName,
quantity: purchase.quantity || 1,
auto_renew_status: purchase.autoRenewing || false,
purchase_state: purchase.purchaseState,
acknowledgement_state: purchase.acknowledgementState,
price_amount_micros: purchase.priceAmountMicros,
price_currency_code: purchase.priceCurrencyCode,
country_code: purchase.countryCode,
developer_payload: purchase.developerPayload,
linked_purchase_token: purchase.linkedPurchaseToken,
obfuscated_external_account_id: purchase.obfuscatedExternalAccountId,
obfuscated_external_profile_id: purchase.obfuscatedExternalProfileId,
cancel_reason: purchase.cancelReason,
user_cancellation_time_millis: purchase.userCancellationTimeMillis,
created_at: new Date(),
updated_at: new Date(),
},
{
onConflict: 'user_id,order_id',
},
);
if (error) {
console.error('Database update error:', error);
throw new Error(`Database update failed: ${error.message}`);
}
const plan = mapProductIdToUserPlan(purchase.productId, true);
await supabase
.from('plans')
.update({
plan: ['active', 'trialing'].includes(purchase.status) ? plan : 'free',
status: purchase.status,
})
.eq('id', userId);
return data;
} catch (error) {
console.error('Failed to update user subscription:', error);
throw error;
}
}
export async function createOrUpdatePayment(userId: string, purchase: VerifiedPurchase) {
try {
const supabase = createSupabaseAdminClient();
const { data: existingPayment } = await supabase
.from('payments')
.select('*')
.eq('google_purchase_token', purchase.purchaseToken)
.single();
if (existingPayment && existingPayment.user_id !== userId) {
throw new Error(IAPError.TRANSACTION_BELONGS_TO_ANOTHER_USER);
}
const paymentData: Partial<GooglePaymentData> = {
user_id: userId,
provider: 'google',
product_id: purchase.productId,
google_order_id: purchase.orderId,
google_purchase_token: purchase.purchaseToken,
storage_gb: isStoragePurchase(purchase.productId) ? parseStorageGB(purchase.productId) : 0,
status: purchase.status === 'active' ? 'completed' : 'failed',
amount: purchase.amount,
currency: purchase.currency,
};
const { data, error } = await supabase.from('payments').upsert(paymentData, {
onConflict: 'google_purchase_token',
ignoreDuplicates: false,
});
if (error) {
console.error('Database payment update error:', error);
throw new Error(`Database payment update failed: ${error.message}`);
}
await updateUserStorage(userId);
return data;
} catch (error) {
console.error('Failed to update user payment:', error);
throw error;
}
}
export async function processPurchaseData(
user: { id: string; email?: string | undefined },
verifyParams: VerifyPurchaseParams,
verificationResult: VerificationResult,
): Promise<VerifiedPurchase> {
const { orderId, purchaseToken, productId, packageName } = verifyParams;
const purchaseData = verificationResult.purchaseData!;
const isSubscription = verificationResult.purchaseType === 'subscription';
// Check environment (test purchases have specific patterns in orderId)
const isTestPurchase = purchaseData.purchaseType === 0; // 0 = Test, 1 = Promo, undefined = Real
if (isTestPurchase && process.env.NODE_ENV === 'production') {
console.warn('Test purchase in production environment');
}
let purchase: VerifiedPurchase;
if (isSubscription) {
const subData = purchaseData as SubscriptionPurchase;
purchase = {
platform: 'android',
status: verificationResult.status!,
customerEmail: user.email!,
orderId: subData.orderId || orderId,
subscriptionId: subData.orderId || orderId,
planName: mapProductIdToProductName(productId),
planType: 'subscription',
productId: productId,
amount: subData.priceAmountMicros ? Number(subData.priceAmountMicros) / 10000 : undefined,
currency: subData.priceCurrencyCode || undefined,
purchaseToken: purchaseToken,
purchaseDate: verificationResult.purchaseDate?.toISOString(),
expiresDate: verificationResult.expiresDate?.toISOString() || null,
quantity: subData.quantity || 1,
environment: isTestPurchase ? 'sandbox' : 'production',
packageName: packageName,
purchaseState: subData.purchaseState,
acknowledgementState: subData.acknowledgementState,
autoRenewing: subData.autoRenewing,
priceAmountMicros: subData.priceAmountMicros,
priceCurrencyCode: subData.priceCurrencyCode,
countryCode: subData.countryCode,
developerPayload: subData.developerPayload,
linkedPurchaseToken: subData.linkedPurchaseToken,
obfuscatedExternalAccountId: subData.obfuscatedExternalAccountId,
obfuscatedExternalProfileId: subData.obfuscatedExternalProfileId,
cancelReason: subData.cancelReason,
userCancellationTimeMillis: subData.userCancellationTimeMillis,
};
} else {
const prodData = purchaseData as ProductPurchase;
purchase = {
platform: 'android',
status: verificationResult.status!,
customerEmail: user.email!,
orderId: prodData.orderId || purchaseToken,
subscriptionId: prodData.orderId || purchaseToken,
planName: mapProductIdToProductName(productId),
planType: 'purchase',
productId: productId,
purchaseToken: purchaseToken,
purchaseDate: verificationResult.purchaseDate?.toISOString(),
expiresDate: null, // One-time purchases don't expire
quantity: prodData.quantity || 1,
environment: isTestPurchase ? 'sandbox' : 'production',
packageName: packageName,
purchaseState: prodData.purchaseState,
acknowledgementState: prodData.acknowledgementState,
autoRenewing: false, // Not applicable for one-time purchases
priceAmountMicros: undefined,
priceCurrencyCode: prodData.regionCode,
countryCode: prodData.regionCode,
developerPayload: prodData.developerPayload,
linkedPurchaseToken: undefined,
obfuscatedExternalAccountId: prodData.obfuscatedExternalAccountId,
obfuscatedExternalProfileId: prodData.obfuscatedExternalProfileId,
cancelReason: null,
userCancellationTimeMillis: null,
};
}
if (isSubscription) {
await createOrUpdateSubscription(user.id, purchase);
} else {
await createOrUpdatePayment(user.id, purchase);
}
return purchase;
}
@@ -1,7 +1,9 @@
import { google, androidpublisher_v3 } from 'googleapis';
import { GoogleAuth, GoogleAuthOptions } from 'google-auth-library';
import { IAPStatus } from '../types';
interface VerifyPurchaseParams {
export interface VerifyPurchaseParams {
orderId: string;
purchaseToken: string;
productId: string;
packageName: string;
@@ -46,16 +48,18 @@ export interface ProductPurchase {
regionCode?: string | null;
}
type PurchaseType = 'subscription' | 'product';
export interface VerificationResult {
success: boolean;
error?: string;
status?: string;
status?: IAPStatus;
purchaseDate?: Date;
expiresDate?: Date | null;
revocationDate?: Date | null;
revocationReason?: number | null;
purchaseData?: SubscriptionPurchase | ProductPurchase;
purchaseType?: 'subscription' | 'product';
purchaseType?: PurchaseType;
}
export class GoogleIAPVerifier {
@@ -130,7 +134,7 @@ export class GoogleIAPVerifier {
const expiryTime = purchase.expiryTimeMillis ? parseInt(purchase.expiryTimeMillis) : 0;
const startTime = purchase.startTimeMillis ? parseInt(purchase.startTimeMillis) : 0;
let status = 'expired';
let status: IAPStatus = 'expired';
if (purchase.paymentState === 1 && expiryTime > now) {
status = 'active';
} else if (purchase.paymentState === 0) {
@@ -149,6 +153,7 @@ export class GoogleIAPVerifier {
: null,
revocationReason: purchase.cancelReason || null,
purchaseData: purchase,
purchaseType: 'subscription',
};
} catch {
return {
@@ -170,7 +175,7 @@ export class GoogleIAPVerifier {
const purchase: ProductPurchase = response.data;
// Check purchase state (0 = purchased, 1 = canceled)
// Check purchase state (0 = purchased, 1 = cancelled)
const status = purchase.purchaseState === 0 ? 'active' : 'cancelled';
const purchaseTime = purchase.purchaseTimeMillis ? parseInt(purchase.purchaseTimeMillis) : 0;
@@ -182,6 +187,7 @@ export class GoogleIAPVerifier {
revocationDate: null,
revocationReason: null,
purchaseData: purchase,
purchaseType: 'product',
};
} catch {
return {
@@ -0,0 +1,37 @@
import { PlanType } from '@/types/quota';
export type IAPStatus =
| 'active'
| 'expired'
| 'cancelled'
| 'in_grace_period'
| 'revoked'
| 'pending';
export interface VerifiedIAP {
platform: 'ios' | 'android';
status: IAPStatus;
customerEmail: string;
orderId: string;
planName: string;
planType: PlanType;
productId: string;
amount?: number;
currency?: string;
/**
* @deprecated Use `orderId` instead.
*/
subscriptionId?: string;
}
export enum IAPError {
INVALID_INPUT = 'Invalid input data',
NOT_AUTHENTICATED = 'Not authenticated',
TRANSACTION_NOT_FOUND = 'Transaction not found',
TRANSACTION_BELONGS_TO_ANOTHER_USER = 'This transaction does not belong to the authenticated user',
TRANSACTION_SERVICE_UNAVAILABLE = 'Transaction service is currently unavailable. Please contact support.',
TRANSACTION_CANNOT_BE_VERIFIED = 'The transaction could not be verified.',
RESTORE_FAILED = 'Failed to restore purchases. Please try again later.',
UNKNOWN_ERROR = 'Unknown error',
}
@@ -0,0 +1,45 @@
import { PlanInterval, PlanType, UserPlan } from '@/types/quota';
export const mapProductIdToUserPlan = (productId: string, isSubscription = false): UserPlan => {
if (productId.includes('.plus')) return 'plus';
if (productId.includes('.pro')) return 'pro';
if (!isSubscription && productId.includes('.purchase')) return 'purchase';
return 'free';
};
export const mapProductIdToInterval = (productId: string): PlanInterval => {
if (productId.includes('.monthly')) return 'month';
if (productId.includes('.yearly')) return 'year';
return 'lifetime';
};
export const mapProductIdToPlanType = (productId: string): PlanType => {
if (productId.includes('.purchase')) return 'purchase';
return 'subscription';
};
export const mapProductIdToProductName = (productId: string): string => {
if (productId.includes('.plus')) return 'Plus';
if (productId.includes('.pro')) return 'Pro';
if (productId.includes('.1gb')) return '1 GB';
if (productId.includes('.2gb')) return '2 GB';
if (productId.includes('.5gb')) return '5 GB';
if (productId.includes('.10gb')) return '10 GB';
return productId;
};
export const isPurchaseProduct = (productId: string): boolean => {
return productId.includes('.purchase');
};
export const isStoragePurchase = (productId: string): boolean => {
return isPurchaseProduct(productId) && productId.includes('.storage');
};
export const parseStorageGB = (productId: string): number => {
const match = productId.match(/\.([0-9]+)gb/);
if (match && match[1]) {
return parseInt(match[1], 10);
}
return 0;
};
@@ -1,6 +1,13 @@
import { loadStripe, Stripe } from '@stripe/stripe-js';
import posthog from 'posthog-js';
import Stripe from 'stripe';
import { loadStripe, Stripe as StripeClient } from '@stripe/stripe-js';
import { getAPIBaseUrl, isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { openUrl } from '@tauri-apps/plugin-opener';
import { getAccessToken } from '@/utils/access';
import { StripeProductMetadata } from '@/types/payment';
import { AvailablePlan, PlanType } from '@/types/quota';
let stripePromise: Promise<Stripe | null>;
let stripePromise: Promise<StripeClient | null>;
export const getStripe = () => {
if (!stripePromise) {
@@ -12,3 +19,110 @@ export const getStripe = () => {
}
return stripePromise;
};
const WEB_STRIPE_PLANS_URL = `${getAPIBaseUrl()}/stripe/plans`;
const WEB_STRIPE_CHECKOUT_URL = `${getAPIBaseUrl()}/stripe/checkout`;
const WEB_STRIPE_PORTAL_URL = `${getAPIBaseUrl()}/stripe/portal`;
const SUBSCRIPTION_SUCCESS_PATH = '/user/subscription/success';
export interface StripeCheckoutResponse {
sessionId?: string;
clientSecret?: string;
url?: string;
}
export type StripeAvailablePlan = AvailablePlan & {
metadata?: StripeProductMetadata;
product?: Stripe.Product;
};
export const fetchStripePlans = async () => {
const response = await fetch(WEB_STRIPE_PLANS_URL);
const data = await response.json();
return data && data instanceof Array ? data : [];
};
export const createStripeCheckoutSession = async (
productId: string,
planType: PlanType = 'subscription',
): Promise<StripeCheckoutResponse> => {
const token = await getAccessToken();
const isEmbeddedCheckout = isTauriAppPlatform();
const response = await fetch(WEB_STRIPE_CHECKOUT_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ priceId: productId, planType, embedded: isEmbeddedCheckout }),
});
if (!response.ok) {
throw new Error('Failed to create Stripe checkout session');
}
return response.json();
};
export const redirectToStripeCheckout = async (sessionId?: string, url?: string): Promise<void> => {
if (url) {
if (isWebAppPlatform()) {
window.location.href = url;
} else if (isTauriAppPlatform()) {
await openUrl(url);
}
} else if (sessionId) {
const stripe = await getStripe();
if (!stripe) {
throw new Error('Stripe not loaded');
}
const result = await stripe.redirectToCheckout({ sessionId });
if (result.error) {
throw result.error;
}
} else {
throw new Error('No sessionId or url returned from checkout API');
}
};
export const createStripePortalSession = async () => {
const token = await getAccessToken();
const response = await fetch(WEB_STRIPE_PORTAL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
return data.url;
};
export const redirectToStripePortal = async (url: string): Promise<void> => {
if (isWebAppPlatform()) {
window.location.href = url;
} else if (isTauriAppPlatform()) {
await openUrl(url);
}
};
export const handleStripeCheckoutError = (error: string) => {
console.error(error);
posthog.capture('checkout_error', { error });
};
export const getSubscriptionSuccessUrl = (sessionId: string) => {
const params = new URLSearchParams({
payment: 'stripe',
session_id: sessionId,
});
return `${SUBSCRIPTION_SUCCESS_PATH}?${params.toString()}`;
};
+1 -9
View File
@@ -1,9 +1 @@
export enum IAPError {
INVALID_INPUT = 'Invalid input data',
NOT_AUTHENTICATED = 'Not authenticated',
TRANSACTION_NOT_FOUND = 'Transaction not found',
TRANSACTION_BELONGS_TO_ANOTHER_USER = 'This transaction does not belong to the authenticated user',
TRANSACTION_SERVICE_UNAVAILABLE = 'Transaction service is currently unavailable. Please contact support.',
RESTORE_FAILED = 'Failed to restore purchases. Please try again later.',
UNKNOWN_ERROR = 'Unknown error',
}
+3 -3
View File
@@ -9,7 +9,7 @@ export type PaymentStatus =
| 'succeeded'
| 'failed'
| 'refunded'
| 'canceled';
| 'cancelled';
export const COMPLETED_PAYMENT_STATUSES: PaymentStatus[] = ['completed', 'succeeded'];
@@ -25,8 +25,8 @@ export interface StripeProductMetadata {
export interface BasePaymentData {
user_id: string;
provider: PaymentProvider;
amount: number;
currency: string;
amount?: number; // in cents
currency?: string;
status: PaymentStatus;
payment_method?: string | null;
product_id?: string;
+12 -3
View File
@@ -5,11 +5,8 @@ export interface UserQuota {
purchase: number;
}
export type UserPlan = keyof UserQuota;
export type UserStorageQuota = UserQuota;
export type UserDailyTranslationQuota = UserQuota;
export type PlanType = 'subscription' | 'purchase';
export type QuotaType = {
name: string;
tooltip: string;
@@ -19,3 +16,15 @@ export type QuotaType = {
};
export type QuotaFeature = 'storage' | 'translation' | 'tokens' | 'customization' | 'generic';
export type UserPlan = keyof UserQuota;
export type PlanType = 'subscription' | 'purchase';
export type PlanInterval = 'month' | 'year' | 'lifetime';
export type AvailablePlan = {
plan: UserPlan;
productId: string;
price: number; // in cents
currency: string;
interval: PlanInterval;
productName: string;
};