feat: add donations feature with one-time payments - M6 (refs #55)

This commit is contained in:
Eric Gullickson
2026-01-18 16:51:20 -06:00
parent 6c1a100eb9
commit 56da99de36
14 changed files with 815 additions and 4 deletions

View File

@@ -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<FastifyInstance> {
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<FastifyInstance> {
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<FastifyInstance> {
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

View File

@@ -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' });
}
}
}

View 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),
});
};

View 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(),
};
}
}

View File

@@ -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<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
*/

View File

@@ -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,
};

View File

@@ -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<string, string>;
}

View File

@@ -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';