Files
motovaultpro/backend/src/features/subscriptions/api/subscriptions.controller.ts
egullickson 7712ec6661
All checks were successful
Deploy to Staging / Build Images (push) Successful in 6m32s
Deploy to Staging / Deploy to Staging (push) Successful in 23s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Merge pull request 'chore: migrate user identity from auth0_sub to UUID' (#219) from issue-206-migrate-user-identity-uuid into main
Reviewed-on: #219
2026-02-16 20:55:39 +00:00

324 lines
9.0 KiB
TypeScript

/**
* @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.userContext!.userId;
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.userContext?.userId,
error: error.message,
});
reply.status(500).send({
error: 'Failed to get subscription',
message: error.message,
});
}
}
/**
* GET /api/subscriptions/needs-vehicle-selection - Check if user needs vehicle selection
*/
async checkNeedsVehicleSelection(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = request.userContext!.userId;
const result = await this.service.checkNeedsVehicleSelection(userId);
reply.status(200).send(result);
} catch (error: any) {
logger.error('Failed to check needs vehicle selection', {
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({
error: 'Failed to check needs vehicle selection',
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.userContext!.userId;
const email = request.userContext!.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 || '',
email
);
reply.status(200).send(updatedSubscription);
} catch (error: any) {
logger.error('Failed to create checkout', {
userId: request.userContext?.userId,
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.userContext!.userId;
const subscription = await this.service.cancelSubscription(userId);
reply.status(200).send(subscription);
} catch (error: any) {
logger.error('Failed to cancel subscription', {
userId: request.userContext?.userId,
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.userContext!.userId;
const subscription = await this.service.reactivateSubscription(userId);
reply.status(200).send(subscription);
} catch (error: any) {
logger.error('Failed to reactivate subscription', {
userId: request.userContext?.userId,
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.userContext!.userId;
const email = request.userContext!.email || '';
const { paymentMethodId } = request.body;
// Validate input
if (!paymentMethodId) {
reply.status(400).send({
error: 'Missing required field',
message: 'paymentMethodId is required',
});
return;
}
// Update payment method via service (creates Stripe customer if needed)
await this.service.updatePaymentMethod(userId, paymentMethodId, email);
reply.status(200).send({
message: 'Payment method updated successfully',
});
} catch (error: any) {
logger.error('Failed to update payment method', {
userId: request.userContext?.userId,
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.userContext!.userId;
const invoices = await this.service.getInvoices(userId);
reply.status(200).send(invoices);
} catch (error: any) {
logger.error('Failed to get invoices', {
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({
error: 'Failed to get invoices',
message: error.message,
});
}
}
/**
* POST /api/subscriptions/downgrade - Downgrade subscription with vehicle selection
*/
async downgrade(
request: FastifyRequest<{
Body: {
targetTier: 'free' | 'pro';
vehicleIdsToKeep: string[];
};
}>,
reply: FastifyReply
): Promise<void> {
try {
const userId = request.userContext!.userId;
const { targetTier, vehicleIdsToKeep } = request.body;
// Validate inputs
if (!targetTier || !vehicleIdsToKeep) {
reply.status(400).send({
error: 'Missing required fields',
message: 'targetTier and vehicleIdsToKeep are required',
});
return;
}
if (!['free', 'pro'].includes(targetTier)) {
reply.status(400).send({
error: 'Invalid tier',
message: 'targetTier must be "free" or "pro"',
});
return;
}
if (!Array.isArray(vehicleIdsToKeep)) {
reply.status(400).send({
error: 'Invalid vehicle selection',
message: 'vehicleIdsToKeep must be an array',
});
return;
}
// Downgrade subscription
const updatedSubscription = await this.service.downgradeSubscription(
userId,
targetTier,
vehicleIdsToKeep
);
reply.status(200).send(updatedSubscription);
} catch (error: any) {
logger.error('Failed to downgrade subscription', {
userId: request.userContext?.userId,
error: error.message,
});
reply.status(500).send({
error: 'Failed to downgrade subscription',
message: error.message,
});
}
}
}