feat: add donations feature with one-time payments - M6 (refs #55)
This commit is contained in:
@@ -33,6 +33,7 @@ import { userPreferencesRoutes } from './features/user-preferences';
|
|||||||
import { userExportRoutes } from './features/user-export';
|
import { userExportRoutes } from './features/user-export';
|
||||||
import { userImportRoutes } from './features/user-import';
|
import { userImportRoutes } from './features/user-import';
|
||||||
import { ownershipCostsRoutes } from './features/ownership-costs';
|
import { ownershipCostsRoutes } from './features/ownership-costs';
|
||||||
|
import { subscriptionsRoutes, donationsRoutes, webhooksRoutes } from './features/subscriptions';
|
||||||
import { pool } from './core/config/database';
|
import { pool } from './core/config/database';
|
||||||
import { configRoutes } from './core/config/config.routes';
|
import { configRoutes } from './core/config/config.routes';
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
|||||||
status: 'healthy',
|
status: 'healthy',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
environment: process.env['NODE_ENV'],
|
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<FastifyInstance> {
|
|||||||
status: 'healthy',
|
status: 'healthy',
|
||||||
scope: 'api',
|
scope: 'api',
|
||||||
timestamp: new Date().toISOString(),
|
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<FastifyInstance> {
|
|||||||
await app.register(userExportRoutes, { prefix: '/api' });
|
await app.register(userExportRoutes, { prefix: '/api' });
|
||||||
await app.register(userImportRoutes, { prefix: '/api' });
|
await app.register(userImportRoutes, { prefix: '/api' });
|
||||||
await app.register(ownershipCostsRoutes, { 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' });
|
await app.register(configRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/src/features/subscriptions/api/donations.routes.ts
Normal file
26
backend/src/features/subscriptions/api/donations.routes.ts
Normal file
@@ -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),
|
||||||
|
});
|
||||||
|
};
|
||||||
150
backend/src/features/subscriptions/domain/donations.service.ts
Normal file
150
backend/src/features/subscriptions/domain/donations.service.ts
Normal file
@@ -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<Donation | null> {
|
||||||
|
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<DonationResponse[]> {
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -400,6 +400,9 @@ export class SubscriptionsService {
|
|||||||
case 'invoice.payment_failed':
|
case 'invoice.payment_failed':
|
||||||
await this.handlePaymentFailed(event);
|
await this.handlePaymentFailed(event);
|
||||||
break;
|
break;
|
||||||
|
case 'payment_intent.succeeded':
|
||||||
|
await this.handleDonationPaymentSucceeded(event);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
logger.info('Unhandled webhook event type', { eventType: event.type });
|
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<void> {
|
||||||
|
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
|
* Sync subscription tier to user_profiles table
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ export class StripeClient {
|
|||||||
status: paymentIntent.status,
|
status: paymentIntent.status,
|
||||||
customer: paymentIntent.customer as string | undefined,
|
customer: paymentIntent.customer as string | undefined,
|
||||||
payment_method: paymentIntent.payment_method as string | undefined,
|
payment_method: paymentIntent.payment_method as string | undefined,
|
||||||
|
client_secret: paymentIntent.client_secret,
|
||||||
created: paymentIntent.created,
|
created: paymentIntent.created,
|
||||||
metadata: paymentIntent.metadata,
|
metadata: paymentIntent.metadata,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface StripePaymentIntent {
|
|||||||
status: 'requires_payment_method' | 'requires_confirmation' | 'requires_action' | 'processing' | 'requires_capture' | 'canceled' | 'succeeded';
|
status: 'requires_payment_method' | 'requires_confirmation' | 'requires_action' | 'processing' | 'requires_capture' | 'canceled' | 'succeeded';
|
||||||
customer?: string;
|
customer?: string;
|
||||||
payment_method?: string;
|
payment_method?: string;
|
||||||
|
client_secret: string | null;
|
||||||
created: number;
|
created: number;
|
||||||
metadata?: Record<string, string>;
|
metadata?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,11 @@ export { StripeClient } from './external/stripe/stripe.client';
|
|||||||
// Repository
|
// Repository
|
||||||
export { SubscriptionsRepository } from './data/subscriptions.repository';
|
export { SubscriptionsRepository } from './data/subscriptions.repository';
|
||||||
|
|
||||||
// Service
|
// Services
|
||||||
export { SubscriptionsService } from './domain/subscriptions.service';
|
export { SubscriptionsService } from './domain/subscriptions.service';
|
||||||
|
export { DonationsService } from './domain/donations.service';
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
export { webhooksRoutes } from './api/webhooks.routes';
|
export { webhooksRoutes } from './api/webhooks.routes';
|
||||||
export { subscriptionsRoutes } from './api/subscriptions.routes';
|
export { subscriptionsRoutes } from './api/subscriptions.routes';
|
||||||
|
export { donationsRoutes } from './api/donations.routes';
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ export const subscriptionApi = {
|
|||||||
updatePaymentMethod: (data: PaymentMethodUpdateRequest) => apiClient.put('/subscriptions/payment-method', data),
|
updatePaymentMethod: (data: PaymentMethodUpdateRequest) => apiClient.put('/subscriptions/payment-method', data),
|
||||||
getInvoices: () => apiClient.get('/subscriptions/invoices'),
|
getInvoices: () => apiClient.get('/subscriptions/invoices'),
|
||||||
downgrade: (data: DowngradeRequest) => apiClient.post('/subscriptions/downgrade', data),
|
downgrade: (data: DowngradeRequest) => apiClient.post('/subscriptions/downgrade', data),
|
||||||
|
createDonation: (amount: number) => apiClient.post('/donations', { amount }),
|
||||||
|
getDonations: () => apiClient.get('/donations'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<DonationSectionProps> = ({ currentTier }) => {
|
||||||
|
const stripe = useStripe();
|
||||||
|
const elements = useElements();
|
||||||
|
const [amount, setAmount] = useState<string>('');
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Card padding="lg">
|
||||||
|
<Typography variant="h6" fontWeight="bold" gutterBottom>
|
||||||
|
Support MotoVaultPro
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{currentTier === 'free' && (
|
||||||
|
<Alert severity="info" sx={{ mb: 3 }}>
|
||||||
|
Love MotoVaultPro? Consider making a one-time donation to support development!
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" gutterBottom sx={{ mb: 3 }}>
|
||||||
|
Your donations help us continue to improve and maintain MotoVaultPro. Thank you for your support!
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{showSuccess && (
|
||||||
|
<Alert severity="success" sx={{ mb: 3 }}>
|
||||||
|
Thank you for your generous donation! Your support means the world to us.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Donation Amount
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="Enter amount"
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <Typography sx={{ mr: 1 }}>$</Typography>,
|
||||||
|
}}
|
||||||
|
inputProps={{
|
||||||
|
min: 0.5,
|
||||||
|
step: 0.01,
|
||||||
|
}}
|
||||||
|
disabled={processing}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Card Details
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 2,
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardElement options={CARD_ELEMENT_OPTIONS} onChange={handleCardChange} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
fullWidth
|
||||||
|
disabled={!isFormValid}
|
||||||
|
startIcon={processing && <CircularProgress size={20} />}
|
||||||
|
>
|
||||||
|
{processing ? 'Processing...' : 'Donate'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{donations.length > 0 && (
|
||||||
|
<Box sx={{ mt: 4 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||||
|
Donation History
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{isLoadingDonations ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Date</TableCell>
|
||||||
|
<TableCell>Amount</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{donations.map((donation: any) => (
|
||||||
|
<TableRow key={donation.id}>
|
||||||
|
<TableCell>{format(new Date(donation.createdAt), 'MMM dd, yyyy')}</TableCell>
|
||||||
|
<TableCell>${(donation.amountCents / 100).toFixed(2)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={donation.status}
|
||||||
|
color={donation.status === 'succeeded' ? 'success' : 'default'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<DonationSectionMobileProps> = ({ currentTier }) => {
|
||||||
|
const stripe = useStripe();
|
||||||
|
const elements = useElements();
|
||||||
|
const [amount, setAmount] = useState<string>('');
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<GlassCard>
|
||||||
|
<h2 className="text-xl font-bold text-slate-800 dark:text-avus mb-3">
|
||||||
|
Support MotoVaultPro
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{currentTier === 'free' && (
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-xl p-3 mb-4">
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
Love MotoVaultPro? Consider making a one-time donation to support development!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-sm text-slate-600 dark:text-titanio mb-4">
|
||||||
|
Your donations help us continue to improve and maintain MotoVaultPro. Thank you for your support!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{showSuccess && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-xl p-3 mb-4">
|
||||||
|
<p className="text-sm text-green-800 dark:text-green-300">
|
||||||
|
Thank you for your generous donation! Your support means the world to us.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-avus mb-2">
|
||||||
|
Donation Amount
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-3 text-slate-600 dark:text-titanio">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => 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]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-avus mb-2">
|
||||||
|
Card Details
|
||||||
|
</label>
|
||||||
|
<div className="border border-slate-200 dark:border-grigio rounded-xl p-3 bg-white dark:bg-nero">
|
||||||
|
<CardElement options={CARD_ELEMENT_OPTIONS} onChange={handleCardChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-xl p-3 mb-4">
|
||||||
|
<p className="text-sm text-red-800 dark:text-red-300">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isFormValid}
|
||||||
|
className={`w-full py-3 px-4 rounded-xl font-semibold min-h-[44px] ${
|
||||||
|
isFormValid
|
||||||
|
? 'bg-rose-500 text-white hover:bg-rose-600'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{processing ? 'Processing...' : 'Donate'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{donations.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="text-lg font-bold text-slate-800 dark:text-avus mb-3">
|
||||||
|
Donation History
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{isLoadingDonations ? (
|
||||||
|
<div className="flex justify-center p-6">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-rose-500"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{donations.map((donation: any) => (
|
||||||
|
<div
|
||||||
|
key={donation.id}
|
||||||
|
className="flex justify-between items-center p-3 bg-slate-50 dark:bg-scuro rounded-xl"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-slate-800 dark:text-avus">
|
||||||
|
{format(new Date(donation.createdAt), 'MMM dd, yyyy')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-600 dark:text-titanio">
|
||||||
|
${(donation.amountCents / 100).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-1 rounded-full ${
|
||||||
|
donation.status === 'succeeded'
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||||
|
: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{donation.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { format } from 'date-fns';
|
|||||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||||
import { PaymentMethodForm } from '../components/PaymentMethodForm';
|
import { PaymentMethodForm } from '../components/PaymentMethodForm';
|
||||||
|
import { DonationSectionMobile } from '../components/DonationSectionMobile';
|
||||||
import {
|
import {
|
||||||
useSubscription,
|
useSubscription,
|
||||||
useCheckout,
|
useCheckout,
|
||||||
@@ -346,6 +347,10 @@ export const SubscriptionMobileScreen: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
|
<Elements stripe={stripePromise}>
|
||||||
|
<DonationSectionMobile currentTier={subscription?.tier} />
|
||||||
|
</Elements>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MobileModal
|
<MobileModal
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { TierCard } from '../components/TierCard';
|
|||||||
import { PaymentMethodForm } from '../components/PaymentMethodForm';
|
import { PaymentMethodForm } from '../components/PaymentMethodForm';
|
||||||
import { BillingHistory } from '../components/BillingHistory';
|
import { BillingHistory } from '../components/BillingHistory';
|
||||||
import { DowngradeFlow } from '../components/DowngradeFlow';
|
import { DowngradeFlow } from '../components/DowngradeFlow';
|
||||||
|
import { DonationSection } from '../components/DonationSection';
|
||||||
import {
|
import {
|
||||||
useSubscription,
|
useSubscription,
|
||||||
useCheckout,
|
useCheckout,
|
||||||
@@ -250,7 +251,7 @@ export const SubscriptionPage: React.FC = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding="lg">
|
<Card padding="lg" className="mb-6">
|
||||||
<Typography variant="h6" fontWeight="bold" gutterBottom>
|
<Typography variant="h6" fontWeight="bold" gutterBottom>
|
||||||
Billing History
|
Billing History
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -264,6 +265,10 @@ export const SubscriptionPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Elements stripe={stripePromise}>
|
||||||
|
<DonationSection currentTier={subscription?.tier} />
|
||||||
|
</Elements>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={showPaymentDialog}
|
open={showPaymentDialog}
|
||||||
onClose={() => !checkoutMutation.isPending && setShowPaymentDialog(false)}
|
onClose={() => !checkoutMutation.isPending && setShowPaymentDialog(false)}
|
||||||
|
|||||||
Reference in New Issue
Block a user