diff --git a/backend/src/app.ts b/backend/src/app.ts index cbe60d0..19a88b9 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -33,6 +33,7 @@ import { userPreferencesRoutes } from './features/user-preferences'; import { userExportRoutes } from './features/user-export'; import { userImportRoutes } from './features/user-import'; import { ownershipCostsRoutes } from './features/ownership-costs'; +import { subscriptionsRoutes, donationsRoutes, webhooksRoutes } from './features/subscriptions'; import { pool } from './core/config/database'; import { configRoutes } from './core/config/config.routes'; @@ -94,7 +95,7 @@ async function buildApp(): Promise { status: 'healthy', timestamp: new Date().toISOString(), environment: process.env['NODE_ENV'], - features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs'] + features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations'] }); }); @@ -104,7 +105,7 @@ async function buildApp(): Promise { status: 'healthy', scope: 'api', timestamp: new Date().toISOString(), - features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs'] + features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations'] }); }); @@ -147,6 +148,9 @@ async function buildApp(): Promise { await app.register(userExportRoutes, { prefix: '/api' }); await app.register(userImportRoutes, { prefix: '/api' }); await app.register(ownershipCostsRoutes, { prefix: '/api' }); + await app.register(subscriptionsRoutes, { prefix: '/api' }); + await app.register(donationsRoutes, { prefix: '/api' }); + await app.register(webhooksRoutes, { prefix: '/api' }); await app.register(configRoutes, { prefix: '/api' }); // 404 handler diff --git a/backend/src/features/subscriptions/api/donations.controller.ts b/backend/src/features/subscriptions/api/donations.controller.ts new file mode 100644 index 0000000..72f5209 --- /dev/null +++ b/backend/src/features/subscriptions/api/donations.controller.ts @@ -0,0 +1,81 @@ +/** + * @ai-summary Donations HTTP controller + * @ai-context Handles donation creation and history endpoints + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { DonationsService } from '../domain/donations.service'; +import { SubscriptionsRepository } from '../data/subscriptions.repository'; +import { StripeClient } from '../external/stripe/stripe.client'; +import { pool } from '../../../core/config/database'; +import { logger } from '../../../core/logging/logger'; + +interface CreateDonationBody { + amount: number; +} + +export class DonationsController { + private service: DonationsService; + + constructor() { + const repository = new SubscriptionsRepository(pool); + const stripeClient = new StripeClient(); + this.service = new DonationsService(repository, stripeClient, pool); + } + + /** + * POST /api/donations - Create donation payment intent + */ + async createDonation(request: FastifyRequest, reply: FastifyReply) { + try { + const userId = (request as any).user.sub; + const { amount } = request.body as CreateDonationBody; + + logger.info('Creating donation', { userId, amount }); + + // Validate amount + if (!amount || amount <= 0) { + return reply.code(400).send({ error: 'Invalid amount' }); + } + + // Convert dollars to cents + const amountCents = Math.round(amount * 100); + + // Create donation + const result = await this.service.createDonation(userId, amountCents); + + return reply.code(201).send(result); + } catch (error: any) { + logger.error('Failed to create donation', { + error: error.message, + }); + + if (error.message.includes('must be at least')) { + return reply.code(400).send({ error: error.message }); + } + + return reply.code(500).send({ error: 'Failed to create donation' }); + } + } + + /** + * GET /api/donations - Get user's donation history + */ + async getDonations(request: FastifyRequest, reply: FastifyReply) { + try { + const userId = (request as any).user.sub; + + logger.info('Getting donations', { userId }); + + const donations = await this.service.getUserDonations(userId); + + return reply.code(200).send(donations); + } catch (error: any) { + logger.error('Failed to get donations', { + error: error.message, + }); + + return reply.code(500).send({ error: 'Failed to get donations' }); + } + } +} diff --git a/backend/src/features/subscriptions/api/donations.routes.ts b/backend/src/features/subscriptions/api/donations.routes.ts new file mode 100644 index 0000000..3d7f16a --- /dev/null +++ b/backend/src/features/subscriptions/api/donations.routes.ts @@ -0,0 +1,26 @@ +/** + * @ai-summary Donations HTTP routes + * @ai-context Defines donation endpoints with authentication + */ + +import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify'; +import { DonationsController } from './donations.controller'; + +export const donationsRoutes: FastifyPluginAsync = async ( + fastify: FastifyInstance, + _opts: FastifyPluginOptions +) => { + const controller = new DonationsController(); + + // POST /api/donations - Create donation + fastify.post('/donations', { + preHandler: [fastify.authenticate], + handler: controller.createDonation.bind(controller), + }); + + // GET /api/donations - Get donation history + fastify.get('/donations', { + preHandler: [fastify.authenticate], + handler: controller.getDonations.bind(controller), + }); +}; diff --git a/backend/src/features/subscriptions/domain/donations.service.ts b/backend/src/features/subscriptions/domain/donations.service.ts new file mode 100644 index 0000000..28ac36e --- /dev/null +++ b/backend/src/features/subscriptions/domain/donations.service.ts @@ -0,0 +1,150 @@ +/** + * @ai-summary Donations business logic and payment processing + * @ai-context Manages one-time donations with Stripe PaymentIntent + */ + +import { Pool } from 'pg'; +import { logger } from '../../../core/logging/logger'; +import { SubscriptionsRepository } from '../data/subscriptions.repository'; +import { StripeClient } from '../external/stripe/stripe.client'; +import { Donation, DonationResponse } from './subscriptions.types'; + +export class DonationsService { + constructor( + private repository: SubscriptionsRepository, + private stripeClient: StripeClient, + _pool: Pool + ) {} + + /** + * Create a payment intent for donation + */ + async createDonation( + userId: string, + amountCents: number, + currency: string = 'usd' + ): Promise<{ clientSecret: string; donationId: string }> { + try { + logger.info('Creating donation', { userId, amountCents, currency }); + + // Validate amount (must be positive, Stripe has $0.50 minimum) + if (amountCents < 50) { + throw new Error('Donation amount must be at least $0.50'); + } + + if (amountCents <= 0) { + throw new Error('Donation amount must be positive'); + } + + // Create Stripe PaymentIntent + const paymentIntent = await this.stripeClient.createPaymentIntent( + amountCents, + currency + ); + + // Create donation record in database (status: pending) + const donation = await this.repository.createDonation({ + userId, + stripePaymentIntentId: paymentIntent.id, + amountCents, + currency, + }); + + logger.info('Donation created', { + donationId: donation.id, + paymentIntentId: paymentIntent.id, + userId, + amountCents, + }); + + // Return clientSecret for frontend to complete payment + if (!paymentIntent.client_secret) { + throw new Error('Payment intent did not return client_secret'); + } + + return { + clientSecret: paymentIntent.client_secret, + donationId: donation.id, + }; + } catch (error: any) { + logger.error('Failed to create donation', { + userId, + amountCents, + currency, + error: error.message, + }); + throw error; + } + } + + /** + * Complete donation after payment succeeds + */ + async completeDonation( + stripePaymentIntentId: string + ): Promise { + try { + logger.info('Completing donation', { stripePaymentIntentId }); + + // Find donation by payment intent ID + const donation = await this.repository.findDonationByPaymentIntentId( + stripePaymentIntentId + ); + + if (!donation) { + logger.warn('Donation not found for payment intent', { stripePaymentIntentId }); + return null; + } + + // Update donation status to 'succeeded' + const updatedDonation = await this.repository.updateDonation(donation.id, { + status: 'succeeded', + }); + + logger.info('Donation completed', { + donationId: donation.id, + stripePaymentIntentId, + }); + + return updatedDonation; + } catch (error: any) { + logger.error('Failed to complete donation', { + stripePaymentIntentId, + error: error.message, + }); + throw error; + } + } + + /** + * Get user's donation history + */ + async getUserDonations(userId: string): Promise { + try { + const donations = await this.repository.findDonationsByUserId(userId); + return donations.map(donation => this.mapToResponse(donation)); + } catch (error: any) { + logger.error('Failed to get user donations', { + userId, + error: error.message, + }); + throw error; + } + } + + /** + * Map donation entity to response DTO + */ + private mapToResponse(donation: Donation): DonationResponse { + return { + id: donation.id, + userId: donation.userId, + stripePaymentIntentId: donation.stripePaymentIntentId, + amountCents: donation.amountCents, + currency: donation.currency, + status: donation.status, + createdAt: donation.createdAt.toISOString(), + updatedAt: donation.updatedAt.toISOString(), + }; + } +} diff --git a/backend/src/features/subscriptions/domain/subscriptions.service.ts b/backend/src/features/subscriptions/domain/subscriptions.service.ts index f422dd4..4f4be22 100644 --- a/backend/src/features/subscriptions/domain/subscriptions.service.ts +++ b/backend/src/features/subscriptions/domain/subscriptions.service.ts @@ -400,6 +400,9 @@ export class SubscriptionsService { case 'invoice.payment_failed': await this.handlePaymentFailed(event); break; + case 'payment_intent.succeeded': + await this.handleDonationPaymentSucceeded(event); + break; default: logger.info('Unhandled webhook event type', { eventType: event.type }); } @@ -598,6 +601,43 @@ export class SubscriptionsService { }); } + /** + * Handle payment_intent.succeeded webhook for donations + */ + private async handleDonationPaymentSucceeded(event: StripeWebhookEvent): Promise { + const paymentIntent = event.data.object; + + // Check if this is a donation (based on metadata) + if (paymentIntent.metadata?.type !== 'donation') { + logger.info('PaymentIntent is not a donation, skipping', { + paymentIntentId: paymentIntent.id, + }); + return; + } + + // Find donation by payment intent ID + const donation = await this.repository.findDonationByPaymentIntentId( + paymentIntent.id + ); + + if (!donation) { + logger.warn('Donation not found for payment intent', { + paymentIntentId: paymentIntent.id, + }); + return; + } + + // Update donation status to succeeded + await this.repository.updateDonation(donation.id, { + status: 'succeeded', + }); + + logger.info('Donation marked as succeeded via webhook', { + donationId: donation.id, + paymentIntentId: paymentIntent.id, + }); + } + /** * Sync subscription tier to user_profiles table */ diff --git a/backend/src/features/subscriptions/external/stripe/stripe.client.ts b/backend/src/features/subscriptions/external/stripe/stripe.client.ts index 1547c4a..d59019c 100644 --- a/backend/src/features/subscriptions/external/stripe/stripe.client.ts +++ b/backend/src/features/subscriptions/external/stripe/stripe.client.ts @@ -217,6 +217,7 @@ export class StripeClient { status: paymentIntent.status, customer: paymentIntent.customer as string | undefined, payment_method: paymentIntent.payment_method as string | undefined, + client_secret: paymentIntent.client_secret, created: paymentIntent.created, metadata: paymentIntent.metadata, }; diff --git a/backend/src/features/subscriptions/external/stripe/stripe.types.ts b/backend/src/features/subscriptions/external/stripe/stripe.types.ts index d26bc1d..7b9e35a 100644 --- a/backend/src/features/subscriptions/external/stripe/stripe.types.ts +++ b/backend/src/features/subscriptions/external/stripe/stripe.types.ts @@ -46,6 +46,7 @@ export interface StripePaymentIntent { status: 'requires_payment_method' | 'requires_confirmation' | 'requires_action' | 'processing' | 'requires_capture' | 'canceled' | 'succeeded'; customer?: string; payment_method?: string; + client_secret: string | null; created: number; metadata?: Record; } diff --git a/backend/src/features/subscriptions/index.ts b/backend/src/features/subscriptions/index.ts index 949f5a8..b26ce49 100644 --- a/backend/src/features/subscriptions/index.ts +++ b/backend/src/features/subscriptions/index.ts @@ -40,9 +40,11 @@ export { StripeClient } from './external/stripe/stripe.client'; // Repository export { SubscriptionsRepository } from './data/subscriptions.repository'; -// Service +// Services export { SubscriptionsService } from './domain/subscriptions.service'; +export { DonationsService } from './domain/donations.service'; // Routes export { webhooksRoutes } from './api/webhooks.routes'; export { subscriptionsRoutes } from './api/subscriptions.routes'; +export { donationsRoutes } from './api/donations.routes'; diff --git a/frontend/src/features/subscription/api/subscription.api.ts b/frontend/src/features/subscription/api/subscription.api.ts index 61df5ed..de97eac 100644 --- a/frontend/src/features/subscription/api/subscription.api.ts +++ b/frontend/src/features/subscription/api/subscription.api.ts @@ -9,4 +9,6 @@ export const subscriptionApi = { updatePaymentMethod: (data: PaymentMethodUpdateRequest) => apiClient.put('/subscriptions/payment-method', data), getInvoices: () => apiClient.get('/subscriptions/invoices'), downgrade: (data: DowngradeRequest) => apiClient.post('/subscriptions/downgrade', data), + createDonation: (amount: number) => apiClient.post('/donations', { amount }), + getDonations: () => apiClient.get('/donations'), }; diff --git a/frontend/src/features/subscription/components/DonationSection.tsx b/frontend/src/features/subscription/components/DonationSection.tsx new file mode 100644 index 0000000..2a5d016 --- /dev/null +++ b/frontend/src/features/subscription/components/DonationSection.tsx @@ -0,0 +1,246 @@ +import React, { useState } from 'react'; +import { + Box, + Typography, + TextField, + Button, + CircularProgress, + Alert, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Chip, +} from '@mui/material'; +import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; +import { format } from 'date-fns'; +import toast from 'react-hot-toast'; +import { Card } from '../../../shared-minimal/components/Card'; +import { useCreateDonation, useDonations } from '../hooks/useSubscription'; +import type { StripeCardElementChangeEvent } from '@stripe/stripe-js'; +import type { SubscriptionTier } from '../types/subscription.types'; + +interface DonationSectionProps { + currentTier?: SubscriptionTier; +} + +const CARD_ELEMENT_OPTIONS = { + style: { + base: { + fontSize: '16px', + color: '#424770', + '::placeholder': { + color: '#aab7c4', + }, + }, + invalid: { + color: '#9e2146', + }, + }, +}; + +export const DonationSection: React.FC = ({ currentTier }) => { + const stripe = useStripe(); + const elements = useElements(); + const [amount, setAmount] = useState(''); + const [error, setError] = useState(null); + const [processing, setProcessing] = useState(false); + const [cardComplete, setCardComplete] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); + + const createDonationMutation = useCreateDonation(); + const { data: donationsData, isLoading: isLoadingDonations } = useDonations(); + + const donations = donationsData?.data || []; + + const handleCardChange = (event: StripeCardElementChangeEvent) => { + setError(event.error?.message || null); + setCardComplete(event.complete); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!stripe || !elements) { + return; + } + + const cardElement = elements.getElement(CardElement); + if (!cardElement) { + return; + } + + // Validate amount + const amountNum = parseFloat(amount); + if (isNaN(amountNum) || amountNum < 0.5) { + setError('Minimum donation amount is $0.50'); + return; + } + + setProcessing(true); + setError(null); + + try { + // Create donation payment intent + const donationResponse = await createDonationMutation.mutateAsync(amountNum); + const { clientSecret } = donationResponse.data; + + // Confirm payment with Stripe + const { error: confirmError } = await stripe.confirmCardPayment(clientSecret, { + payment_method: { + card: cardElement, + }, + }); + + if (confirmError) { + setError(confirmError.message || 'Payment failed'); + setProcessing(false); + return; + } + + // Success! + setShowSuccess(true); + setAmount(''); + cardElement.clear(); + toast.success('Thank you for your donation!'); + + // Hide success message after 5 seconds + setTimeout(() => { + setShowSuccess(false); + }, 5000); + } catch (err: unknown) { + const error = err as { response?: { data?: { error?: string } } }; + setError(error.response?.data?.error || 'An unexpected error occurred'); + } finally { + setProcessing(false); + } + }; + + const isFormValid = amount && parseFloat(amount) >= 0.5 && cardComplete && !processing; + + return ( + + + Support MotoVaultPro + + + {currentTier === 'free' && ( + + Love MotoVaultPro? Consider making a one-time donation to support development! + + )} + + + Your donations help us continue to improve and maintain MotoVaultPro. Thank you for your support! + + + {showSuccess && ( + + Thank you for your generous donation! Your support means the world to us. + + )} + +
+ + + Donation Amount + + setAmount(e.target.value)} + placeholder="Enter amount" + InputProps={{ + startAdornment: $, + }} + inputProps={{ + min: 0.5, + step: 0.01, + }} + disabled={processing} + /> + + + + + Card Details + + + + + + + {error && ( + + {error} + + )} + + +
+ + {donations.length > 0 && ( + + + Donation History + + + {isLoadingDonations ? ( + + + + ) : ( + + + + + Date + Amount + Status + + + + {donations.map((donation: any) => ( + + {format(new Date(donation.createdAt), 'MMM dd, yyyy')} + ${(donation.amountCents / 100).toFixed(2)} + + + + + ))} + +
+
+ )} +
+ )} +
+ ); +}; diff --git a/frontend/src/features/subscription/components/DonationSectionMobile.tsx b/frontend/src/features/subscription/components/DonationSectionMobile.tsx new file mode 100644 index 0000000..6c46769 --- /dev/null +++ b/frontend/src/features/subscription/components/DonationSectionMobile.tsx @@ -0,0 +1,224 @@ +import React, { useState } from 'react'; +import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; +import { format } from 'date-fns'; +import toast from 'react-hot-toast'; +import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; +import { useCreateDonation, useDonations } from '../hooks/useSubscription'; +import type { StripeCardElementChangeEvent } from '@stripe/stripe-js'; +import type { SubscriptionTier } from '../types/subscription.types'; + +interface DonationSectionMobileProps { + currentTier?: SubscriptionTier; +} + +const CARD_ELEMENT_OPTIONS = { + style: { + base: { + fontSize: '16px', + color: '#424770', + '::placeholder': { + color: '#aab7c4', + }, + }, + invalid: { + color: '#9e2146', + }, + }, +}; + +export const DonationSectionMobile: React.FC = ({ currentTier }) => { + const stripe = useStripe(); + const elements = useElements(); + const [amount, setAmount] = useState(''); + const [error, setError] = useState(null); + const [processing, setProcessing] = useState(false); + const [cardComplete, setCardComplete] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); + + const createDonationMutation = useCreateDonation(); + const { data: donationsData, isLoading: isLoadingDonations } = useDonations(); + + const donations = donationsData?.data || []; + + const handleCardChange = (event: StripeCardElementChangeEvent) => { + setError(event.error?.message || null); + setCardComplete(event.complete); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!stripe || !elements) { + return; + } + + const cardElement = elements.getElement(CardElement); + if (!cardElement) { + return; + } + + // Validate amount + const amountNum = parseFloat(amount); + if (isNaN(amountNum) || amountNum < 0.5) { + setError('Minimum donation amount is $0.50'); + return; + } + + setProcessing(true); + setError(null); + + try { + // Create donation payment intent + const donationResponse = await createDonationMutation.mutateAsync(amountNum); + const { clientSecret } = donationResponse.data; + + // Confirm payment with Stripe + const { error: confirmError } = await stripe.confirmCardPayment(clientSecret, { + payment_method: { + card: cardElement, + }, + }); + + if (confirmError) { + setError(confirmError.message || 'Payment failed'); + setProcessing(false); + return; + } + + // Success! + setShowSuccess(true); + setAmount(''); + cardElement.clear(); + toast.success('Thank you for your donation!'); + + // Hide success message after 5 seconds + setTimeout(() => { + setShowSuccess(false); + }, 5000); + } catch (err: unknown) { + const error = err as { response?: { data?: { error?: string } } }; + setError(error.response?.data?.error || 'An unexpected error occurred'); + } finally { + setProcessing(false); + } + }; + + const isFormValid = amount && parseFloat(amount) >= 0.5 && cardComplete && !processing; + + return ( + +

+ Support MotoVaultPro +

+ + {currentTier === 'free' && ( +
+

+ Love MotoVaultPro? Consider making a one-time donation to support development! +

+
+ )} + +

+ Your donations help us continue to improve and maintain MotoVaultPro. Thank you for your support! +

+ + {showSuccess && ( +
+

+ Thank you for your generous donation! Your support means the world to us. +

+
+ )} + +
+
+ +
+ $ + setAmount(e.target.value)} + placeholder="Enter amount" + min="0.5" + step="0.01" + disabled={processing} + className="w-full pl-8 pr-4 py-3 bg-white dark:bg-nero border border-slate-200 dark:border-grigio rounded-xl text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-titanio focus:outline-none focus:ring-2 focus:ring-rose-500 min-h-[44px]" + /> +
+
+ +
+ +
+ +
+
+ + {error && ( +
+

{error}

+
+ )} + + +
+ + {donations.length > 0 && ( +
+

+ Donation History +

+ + {isLoadingDonations ? ( +
+
+
+ ) : ( +
+ {donations.map((donation: any) => ( +
+
+
+ {format(new Date(donation.createdAt), 'MMM dd, yyyy')} +
+
+ ${(donation.amountCents / 100).toFixed(2)} +
+
+ + {donation.status} + +
+ ))} +
+ )} +
+ )} +
+ ); +}; diff --git a/frontend/src/features/subscription/hooks/useSubscription.ts b/frontend/src/features/subscription/hooks/useSubscription.ts index 225ed4a..f091be7 100644 --- a/frontend/src/features/subscription/hooks/useSubscription.ts +++ b/frontend/src/features/subscription/hooks/useSubscription.ts @@ -91,3 +91,27 @@ export const useDowngrade = () => { }, }); }; + +export const useCreateDonation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (amount: number) => subscriptionApi.createDonation(amount), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['donations'] }); + }, + onError: (error: unknown) => { + const err = error as { response?: { data?: { error?: string } } }; + toast.error(err.response?.data?.error || 'Donation failed'); + }, + }); +}; + +export const useDonations = () => { + const { isAuthenticated, isLoading } = useAuth0(); + return useQuery({ + queryKey: ['donations'], + queryFn: () => subscriptionApi.getDonations(), + enabled: isAuthenticated && !isLoading, + staleTime: 5 * 60 * 1000, + }); +}; diff --git a/frontend/src/features/subscription/mobile/SubscriptionMobileScreen.tsx b/frontend/src/features/subscription/mobile/SubscriptionMobileScreen.tsx index c622685..77bcd47 100644 --- a/frontend/src/features/subscription/mobile/SubscriptionMobileScreen.tsx +++ b/frontend/src/features/subscription/mobile/SubscriptionMobileScreen.tsx @@ -5,6 +5,7 @@ import { format } from 'date-fns'; import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer'; import { PaymentMethodForm } from '../components/PaymentMethodForm'; +import { DonationSectionMobile } from '../components/DonationSectionMobile'; import { useSubscription, useCheckout, @@ -346,6 +347,10 @@ export const SubscriptionMobileScreen: React.FC = () => { )} + + + + { - + Billing History @@ -264,6 +265,10 @@ export const SubscriptionPage: React.FC = () => { )} + + + + !checkoutMutation.isPending && setShowPaymentDialog(false)}