feat: add donations feature with one-time payments - M6 (refs #55)
This commit is contained in:
@@ -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':
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user