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:
Eric Gullickson
2026-01-18 16:16:58 -06:00
parent 7a0c09b83f
commit e7461a4836
8 changed files with 744 additions and 1 deletions

View File

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