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;
+}