feat(iap): support expanding cloud storage with IAP on iOS and Android (#2331)
This commit is contained in:
@@ -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 d’abonnement :",
|
||||
"Go to Library": "Aller à la bibliothèque",
|
||||
"Need help? Contact our support team at support@readest.com": "Besoin d’aide ? 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
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
+49
-32
@@ -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;
|
||||
}
|
||||
+11
-5
@@ -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 +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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user