diff --git a/backend/src/core/scheduler/index.ts b/backend/src/core/scheduler/index.ts index c6dc63c..2012809 100644 --- a/backend/src/core/scheduler/index.ts +++ b/backend/src/core/scheduler/index.ts @@ -19,6 +19,10 @@ import { processAuditLogCleanup, setAuditLogCleanupJobPool, } from '../../features/audit-log/jobs/cleanup.job'; +import { + processGracePeriodExpirations, + setGracePeriodJobPool, +} from '../../features/subscriptions/jobs/grace-period.job'; import { pool } from '../config/database'; let schedulerInitialized = false; @@ -38,6 +42,9 @@ export function initializeScheduler(): void { // Initialize audit log cleanup job pool setAuditLogCleanupJobPool(pool); + // Initialize grace period job pool + setGracePeriodJobPool(pool); + // Daily notification processing at 8 AM cron.schedule('0 8 * * *', async () => { logger.info('Running scheduled notification job'); @@ -67,6 +74,23 @@ export function initializeScheduler(): void { } }); + // Grace period expiration check at 2:30 AM daily + cron.schedule('30 2 * * *', async () => { + logger.info('Running grace period expiration job'); + try { + const result = await processGracePeriodExpirations(); + logger.info('Grace period job completed', { + processed: result.processed, + downgraded: result.downgraded, + errors: result.errors.length, + }); + } catch (error) { + logger.error('Grace period job failed', { + error: error instanceof Error ? error.message : String(error) + }); + } + }); + // Check for scheduled backups every minute cron.schedule('* * * * *', async () => { logger.debug('Checking for scheduled backups'); @@ -120,7 +144,7 @@ export function initializeScheduler(): void { }); schedulerInitialized = true; - logger.info('Cron scheduler initialized - notification (8 AM), account purge (2 AM), audit log cleanup (3 AM), backup check (every min), retention cleanup (4 AM)'); + logger.info('Cron scheduler initialized - notification (8 AM), account purge (2 AM), grace period (2:30 AM), audit log cleanup (3 AM), backup check (every min), retention cleanup (4 AM)'); } export function isSchedulerInitialized(): boolean { diff --git a/backend/src/features/notifications/migrations/006_payment_email_templates.sql b/backend/src/features/notifications/migrations/006_payment_email_templates.sql new file mode 100644 index 0000000..4966d42 --- /dev/null +++ b/backend/src/features/notifications/migrations/006_payment_email_templates.sql @@ -0,0 +1,239 @@ +/** + * Migration: Add payment failure email templates + * @ai-summary Adds email templates for payment failures during grace period + * @ai-context Three templates: immediate, 7-day warning, 1-day warning + */ + +-- Extend template_key CHECK constraint to include payment failure templates +ALTER TABLE email_templates +DROP CONSTRAINT IF EXISTS email_templates_template_key_check; + +ALTER TABLE email_templates +ADD CONSTRAINT email_templates_template_key_check +CHECK (template_key IN ( + 'maintenance_due_soon', 'maintenance_overdue', + 'document_expiring', 'document_expired', + 'payment_failed_immediate', 'payment_failed_7day', 'payment_failed_1day' +)); + +-- Insert payment failure email templates +INSERT INTO email_templates (template_key, name, description, subject, body, variables, html_body) VALUES + ( + 'payment_failed_immediate', + 'Payment Failed - Immediate Notice', + 'Sent immediately when a subscription payment fails', + 'MotoVaultPro: Payment Failed - Action Required', + 'Hi {{userName}}, + +We were unable to process your payment for your {{tier}} subscription. + +Your subscription will remain active for 30 days while we attempt to collect payment. After 30 days, your subscription will be downgraded to the free tier. + +Please update your payment method to avoid interruption of service. + +Amount Due: ${{amount}} +Next Retry: {{retryDate}} + +Best regards, +MotoVaultPro Team', + '["userName", "tier", "amount", "retryDate"]', + ' + + + + + Payment Failed + + + + + + +
+ + + + + + + + + + +
+

Payment Failed

+
+

Hi {{userName}},

+

We were unable to process your payment for your {{tier}} subscription.

+

Your subscription will remain active for 30 days while we attempt to collect payment. After 30 days, your subscription will be downgraded to the free tier.

+

Please update your payment method to avoid interruption of service.

+ + + + +
+

Amount Due: ${{amount}}

+

Next Retry: {{retryDate}}

+
+ +
+

Best regards,
MotoVaultPro Team

+
+
+ +' + ), + ( + 'payment_failed_7day', + 'Payment Failed - 7 Days Left', + 'Sent 7 days before grace period ends', + 'MotoVaultPro: Urgent - 7 Days Until Downgrade', + 'Hi {{userName}}, + +This is an urgent reminder that your {{tier}} subscription payment is still outstanding. + +Your subscription will be downgraded to the free tier in 7 days if payment is not received. + +Amount Due: ${{amount}} +Grace Period Ends: {{gracePeriodEnd}} + +Please update your payment method immediately to avoid losing access to premium features. + +Best regards, +MotoVaultPro Team', + '["userName", "tier", "amount", "gracePeriodEnd"]', + ' + + + + + Payment Reminder - 7 Days Left + + + + + + +
+ + + + + + + + + + +
+

Urgent: 7 Days Until Downgrade

+
+

Hi {{userName}},

+

This is an urgent reminder that your {{tier}} subscription payment is still outstanding.

+
+

Your subscription will be downgraded in 7 days

+

If payment is not received by {{gracePeriodEnd}}, you will lose access to premium features.

+
+ + + + +
+

Amount Due: ${{amount}}

+

Grace Period Ends: {{gracePeriodEnd}}

+
+ +
+

Best regards,
MotoVaultPro Team

+
+
+ +' + ), + ( + 'payment_failed_1day', + 'Payment Failed - Final Notice', + 'Sent 1 day before grace period ends', + 'MotoVaultPro: FINAL NOTICE - Downgrade Tomorrow', + 'Hi {{userName}}, + +FINAL NOTICE: Your {{tier}} subscription will be downgraded to the free tier tomorrow if payment is not received. + +Amount Due: ${{amount}} +Grace Period Ends: {{gracePeriodEnd}} + +This is your last chance to update your payment method and keep your premium features. + +After downgrade: +- Access to premium features will be lost +- Data remains safe but with reduced vehicle limits +- You can resubscribe at any time + +Please update your payment method now to avoid interruption. + +Best regards, +MotoVaultPro Team', + '["userName", "tier", "amount", "gracePeriodEnd"]', + ' + + + + + Final Notice - Downgrade Tomorrow + + + + + + +
+ + + + + + + + + + +
+

FINAL NOTICE

+

Downgrade Tomorrow

+
+

Hi {{userName}},

+
+

FINAL NOTICE

+

Your {{tier}} subscription will be downgraded to the free tier tomorrow if payment is not received.

+
+ + + + +
+

Amount Due: ${{amount}}

+

Grace Period Ends: {{gracePeriodEnd}}

+
+

This is your last chance to update your payment method and keep your premium features.

+
+

After downgrade:

+
    +
  • Access to premium features will be lost
  • +
  • Data remains safe but with reduced vehicle limits
  • +
  • You can resubscribe at any time
  • +
+
+ +
+

Best regards,
MotoVaultPro Team

+
+
+ +' + ); diff --git a/backend/src/features/subscriptions/api/subscriptions.controller.ts b/backend/src/features/subscriptions/api/subscriptions.controller.ts new file mode 100644 index 0000000..7d4e03e --- /dev/null +++ b/backend/src/features/subscriptions/api/subscriptions.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }); + } + } +} diff --git a/backend/src/features/subscriptions/api/subscriptions.routes.ts b/backend/src/features/subscriptions/api/subscriptions.routes.ts new file mode 100644 index 0000000..25e4789 --- /dev/null +++ b/backend/src/features/subscriptions/api/subscriptions.routes.ts @@ -0,0 +1,51 @@ +/** + * @ai-summary Fastify routes for subscriptions API + * @ai-context Route definitions with authentication for subscription management + */ + +import { FastifyInstance, FastifyPluginOptions } from 'fastify'; +import { FastifyPluginAsync } from 'fastify'; +import { SubscriptionsController } from './subscriptions.controller'; + +export const subscriptionsRoutes: FastifyPluginAsync = async ( + fastify: FastifyInstance, + _opts: FastifyPluginOptions +) => { + const subscriptionsController = new SubscriptionsController(); + + // GET /api/subscriptions - Get current subscription + fastify.get('/subscriptions', { + preHandler: [fastify.authenticate], + handler: subscriptionsController.getSubscription.bind(subscriptionsController) + }); + + // POST /api/subscriptions/checkout - Create Stripe checkout session + fastify.post('/subscriptions/checkout', { + preHandler: [fastify.authenticate], + handler: subscriptionsController.createCheckout.bind(subscriptionsController) + }); + + // POST /api/subscriptions/cancel - Schedule cancellation + fastify.post('/subscriptions/cancel', { + preHandler: [fastify.authenticate], + handler: subscriptionsController.cancelSubscription.bind(subscriptionsController) + }); + + // POST /api/subscriptions/reactivate - Cancel pending cancellation + fastify.post('/subscriptions/reactivate', { + preHandler: [fastify.authenticate], + handler: subscriptionsController.reactivateSubscription.bind(subscriptionsController) + }); + + // PUT /api/subscriptions/payment-method - Update payment method + fastify.put('/subscriptions/payment-method', { + preHandler: [fastify.authenticate], + handler: subscriptionsController.updatePaymentMethod.bind(subscriptionsController) + }); + + // GET /api/subscriptions/invoices - Get billing history + fastify.get('/subscriptions/invoices', { + preHandler: [fastify.authenticate], + handler: subscriptionsController.getInvoices.bind(subscriptionsController) + }); +}; diff --git a/backend/src/features/subscriptions/domain/subscriptions.service.ts b/backend/src/features/subscriptions/domain/subscriptions.service.ts index 2729e86..14f26e4 100644 --- a/backend/src/features/subscriptions/domain/subscriptions.service.ts +++ b/backend/src/features/subscriptions/domain/subscriptions.service.ts @@ -599,6 +599,25 @@ export class SubscriptionsService { return 'free'; } + /** + * Get invoices for a user's subscription + */ + async getInvoices(userId: string): Promise { + try { + const subscription = await this.repository.findByUserId(userId); + if (!subscription?.stripeCustomerId) { + return []; + } + return this.stripeClient.listInvoices(subscription.stripeCustomerId); + } catch (error: any) { + logger.error('Failed to get invoices', { + userId, + error: error.message, + }); + throw error; + } + } + /** * Map subscription entity to response DTO */ diff --git a/backend/src/features/subscriptions/external/stripe/stripe.client.ts b/backend/src/features/subscriptions/external/stripe/stripe.client.ts index 58809d2..1547c4a 100644 --- a/backend/src/features/subscriptions/external/stripe/stripe.client.ts +++ b/backend/src/features/subscriptions/external/stripe/stripe.client.ts @@ -323,4 +323,32 @@ export class StripeClient { throw error; } } + + /** + * List invoices for a customer + */ + async listInvoices(customerId: string): Promise { + try { + logger.info('Listing Stripe invoices', { customerId }); + + const invoices = await this.stripe.invoices.list({ + customer: customerId, + limit: 20, + }); + + logger.info('Stripe invoices retrieved', { + customerId, + count: invoices.data.length + }); + + return invoices.data; + } catch (error: any) { + logger.error('Failed to list Stripe invoices', { + customerId, + error: error.message, + code: error.code, + }); + throw error; + } + } } diff --git a/backend/src/features/subscriptions/index.ts b/backend/src/features/subscriptions/index.ts index 92a24b8..949f5a8 100644 --- a/backend/src/features/subscriptions/index.ts +++ b/backend/src/features/subscriptions/index.ts @@ -45,3 +45,4 @@ export { SubscriptionsService } from './domain/subscriptions.service'; // Routes export { webhooksRoutes } from './api/webhooks.routes'; +export { subscriptionsRoutes } from './api/subscriptions.routes'; diff --git a/backend/src/features/subscriptions/jobs/grace-period.job.ts b/backend/src/features/subscriptions/jobs/grace-period.job.ts new file mode 100644 index 0000000..8e8553b --- /dev/null +++ b/backend/src/features/subscriptions/jobs/grace-period.job.ts @@ -0,0 +1,132 @@ +/** + * @ai-summary Grace period expiration job + * @ai-context Processes expired grace periods and downgrades subscriptions to free tier + */ + +import { Pool } from 'pg'; +import { logger } from '../../../core/logging/logger'; + +let jobPool: Pool | null = null; + +export function setGracePeriodJobPool(pool: Pool): void { + jobPool = pool; +} + +interface GracePeriodResult { + processed: number; + downgraded: number; + errors: string[]; +} + +/** + * Process grace period expirations + * Finds subscriptions with expired grace periods and downgrades them to free tier + */ +export async function processGracePeriodExpirations(): Promise { + if (!jobPool) { + throw new Error('Grace period job pool not initialized'); + } + + const result: GracePeriodResult = { + processed: 0, + downgraded: 0, + errors: [], + }; + + const client = await jobPool.connect(); + + try { + // Find subscriptions with expired grace periods + const query = ` + SELECT id, user_id, tier, stripe_subscription_id + FROM subscriptions + WHERE status = 'past_due' + AND grace_period_end < NOW() + ORDER BY grace_period_end ASC + `; + + const queryResult = await client.query(query); + const expiredSubscriptions = queryResult.rows; + + result.processed = expiredSubscriptions.length; + + logger.info('Processing expired grace periods', { + count: expiredSubscriptions.length, + }); + + // Process each expired subscription + for (const subscription of expiredSubscriptions) { + try { + // Start transaction for this subscription + await client.query('BEGIN'); + + // Update subscription to free tier and unpaid status + const updateQuery = ` + UPDATE subscriptions + SET + tier = 'free', + status = 'unpaid', + stripe_subscription_id = NULL, + billing_cycle = NULL, + current_period_start = NULL, + current_period_end = NULL, + grace_period_end = NULL, + cancel_at_period_end = false, + updated_at = NOW() + WHERE id = $1 + `; + + await client.query(updateQuery, [subscription.id]); + + // Sync tier to user_profiles table + const syncQuery = ` + UPDATE user_profiles + SET + subscription_tier = 'free', + updated_at = NOW() + WHERE user_id = $1 + `; + + await client.query(syncQuery, [subscription.user_id]); + + // Commit transaction + await client.query('COMMIT'); + + result.downgraded++; + + logger.info('Grace period expired - downgraded to free', { + subscriptionId: subscription.id, + userId: subscription.user_id, + previousTier: subscription.tier, + }); + } catch (error: any) { + // Rollback transaction on error + await client.query('ROLLBACK'); + + const errorMsg = `Failed to downgrade subscription ${subscription.id}: ${error.message}`; + result.errors.push(errorMsg); + + logger.error('Failed to process grace period expiration', { + subscriptionId: subscription.id, + userId: subscription.user_id, + error: error.message, + }); + } + } + + logger.info('Grace period expiration job completed', { + processed: result.processed, + downgraded: result.downgraded, + errors: result.errors.length, + }); + } catch (error: any) { + logger.error('Grace period job failed', { + error: error.message, + }); + throw error; + } finally { + client.release(); + } + + return result; +}