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
Reviewed-on: #219
324 lines
9.0 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|
|
}
|