feat: add subscription API endpoints and grace period job - M3 (refs #55)
API Endpoints (all authenticated): - GET /api/subscriptions - current subscription status - POST /api/subscriptions/checkout - create Stripe subscription - POST /api/subscriptions/cancel - schedule cancellation at period end - POST /api/subscriptions/reactivate - cancel pending cancellation - PUT /api/subscriptions/payment-method - update payment method - GET /api/subscriptions/invoices - billing history Grace Period Job: - Daily cron at 2:30 AM to check expired grace periods - Downgrades to free tier when 30-day grace period expires - Syncs tier to user_profiles.subscription_tier Email Templates: - payment_failed_immediate (first failure) - payment_failed_7day (7 days before grace ends) - payment_failed_1day (1 day before grace ends) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* @ai-summary Subscriptions API controller
|
||||
* @ai-context Handles subscription management API requests
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { SubscriptionsService } from '../domain/subscriptions.service';
|
||||
import { SubscriptionsRepository } from '../data/subscriptions.repository';
|
||||
import { StripeClient } from '../external/stripe/stripe.client';
|
||||
import { pool } from '../../../core/config/database';
|
||||
|
||||
export class SubscriptionsController {
|
||||
private service: SubscriptionsService;
|
||||
|
||||
constructor() {
|
||||
const repository = new SubscriptionsRepository(pool);
|
||||
const stripeClient = new StripeClient();
|
||||
this.service = new SubscriptionsService(repository, stripeClient, pool);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/subscriptions - Get current subscription
|
||||
*/
|
||||
async getSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
|
||||
const subscription = await this.service.getSubscription(userId);
|
||||
|
||||
if (!subscription) {
|
||||
reply.status(404).send({
|
||||
error: 'Subscription not found',
|
||||
message: 'No subscription exists for this user',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reply.status(200).send(subscription);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get subscription', {
|
||||
userId: (request as any).user?.sub,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
error: 'Failed to get subscription',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/subscriptions/checkout - Create Stripe checkout session
|
||||
*/
|
||||
async createCheckout(
|
||||
request: FastifyRequest<{
|
||||
Body: {
|
||||
tier: 'pro' | 'enterprise';
|
||||
billingCycle: 'monthly' | 'yearly';
|
||||
paymentMethodId?: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const email = (request as any).user.email;
|
||||
const { tier, billingCycle, paymentMethodId } = request.body;
|
||||
|
||||
// Validate inputs
|
||||
if (!tier || !billingCycle) {
|
||||
reply.status(400).send({
|
||||
error: 'Missing required fields',
|
||||
message: 'tier and billingCycle are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!['pro', 'enterprise'].includes(tier)) {
|
||||
reply.status(400).send({
|
||||
error: 'Invalid tier',
|
||||
message: 'tier must be "pro" or "enterprise"',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!['monthly', 'yearly'].includes(billingCycle)) {
|
||||
reply.status(400).send({
|
||||
error: 'Invalid billing cycle',
|
||||
message: 'billingCycle must be "monthly" or "yearly"',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create or get existing subscription
|
||||
let subscription = await this.service.getSubscription(userId);
|
||||
if (!subscription) {
|
||||
await this.service.createSubscription(userId, email);
|
||||
subscription = await this.service.getSubscription(userId);
|
||||
}
|
||||
|
||||
if (!subscription) {
|
||||
reply.status(500).send({
|
||||
error: 'Failed to create subscription',
|
||||
message: 'Could not initialize subscription',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Upgrade subscription
|
||||
const updatedSubscription = await this.service.upgradeSubscription(
|
||||
userId,
|
||||
tier,
|
||||
billingCycle,
|
||||
paymentMethodId || ''
|
||||
);
|
||||
|
||||
reply.status(200).send(updatedSubscription);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to create checkout', {
|
||||
userId: (request as any).user?.sub,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
error: 'Failed to create checkout',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/subscriptions/cancel - Schedule cancellation
|
||||
*/
|
||||
async cancelSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
|
||||
const subscription = await this.service.cancelSubscription(userId);
|
||||
|
||||
reply.status(200).send(subscription);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to cancel subscription', {
|
||||
userId: (request as any).user?.sub,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
error: 'Failed to cancel subscription',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/subscriptions/reactivate - Cancel pending cancellation
|
||||
*/
|
||||
async reactivateSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
|
||||
const subscription = await this.service.reactivateSubscription(userId);
|
||||
|
||||
reply.status(200).send(subscription);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to reactivate subscription', {
|
||||
userId: (request as any).user?.sub,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
error: 'Failed to reactivate subscription',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/subscriptions/payment-method - Update payment method
|
||||
*/
|
||||
async updatePaymentMethod(
|
||||
request: FastifyRequest<{
|
||||
Body: {
|
||||
paymentMethodId: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const { paymentMethodId } = request.body;
|
||||
|
||||
// Validate input
|
||||
if (!paymentMethodId) {
|
||||
reply.status(400).send({
|
||||
error: 'Missing required field',
|
||||
message: 'paymentMethodId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get subscription
|
||||
const subscription = await this.service.getSubscription(userId);
|
||||
if (!subscription) {
|
||||
reply.status(404).send({
|
||||
error: 'Subscription not found',
|
||||
message: 'No subscription exists for this user',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Update payment method via Stripe
|
||||
const stripeClient = new StripeClient();
|
||||
await stripeClient.updatePaymentMethod(subscription.stripeCustomerId, paymentMethodId);
|
||||
|
||||
reply.status(200).send({
|
||||
message: 'Payment method updated successfully',
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to update payment method', {
|
||||
userId: (request as any).user?.sub,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
error: 'Failed to update payment method',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/subscriptions/invoices - Get billing history
|
||||
*/
|
||||
async getInvoices(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
|
||||
const invoices = await this.service.getInvoices(userId);
|
||||
|
||||
reply.status(200).send(invoices);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get invoices', {
|
||||
userId: (request as any).user?.sub,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
error: 'Failed to get invoices',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user