feat: Accept Payments - Stripe Integration with User Tiers (#55) #56
@@ -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 {
|
||||
|
||||
@@ -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"]',
|
||||
'<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Payment Failed</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="background-color: #d32f2f; padding: 30px; text-align: center;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Payment Failed</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
|
||||
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">We were unable to process your payment for your <strong>{{tier}}</strong> subscription.</p>
|
||||
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Your subscription will remain active for <strong>30 days</strong> while we attempt to collect payment. After 30 days, your subscription will be downgraded to the free tier.</p>
|
||||
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Please update your payment method to avoid interruption of service.</p>
|
||||
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
|
||||
<tr>
|
||||
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #d32f2f;">
|
||||
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Amount Due:</strong> ${{amount}}</p>
|
||||
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Next Retry:</strong> {{retryDate}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="https://app.motovaultpro.com/settings/billing" style="display: inline-block; padding: 14px 28px; background-color: #1976d2; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: bold;">Update Payment Method</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
|
||||
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>'
|
||||
),
|
||||
(
|
||||
'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"]',
|
||||
'<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Payment Reminder - 7 Days Left</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="background-color: #f57c00; padding: 30px; text-align: center;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Urgent: 7 Days Until Downgrade</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
|
||||
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">This is an urgent reminder that your <strong>{{tier}}</strong> subscription payment is still outstanding.</p>
|
||||
<div style="background-color: #fff3e0; border-left: 4px solid #f57c00; padding: 20px; margin: 20px 0;">
|
||||
<p style="color: #e65100; font-size: 18px; font-weight: bold; margin: 0 0 10px 0;">Your subscription will be downgraded in 7 days</p>
|
||||
<p style="color: #333333; font-size: 14px; margin: 0;">If payment is not received by {{gracePeriodEnd}}, you will lose access to premium features.</p>
|
||||
</div>
|
||||
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
|
||||
<tr>
|
||||
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #f57c00;">
|
||||
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Amount Due:</strong> ${{amount}}</p>
|
||||
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Grace Period Ends:</strong> {{gracePeriodEnd}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="https://app.motovaultpro.com/settings/billing" style="display: inline-block; padding: 14px 28px; background-color: #f57c00; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: bold;">Update Payment Now</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
|
||||
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>'
|
||||
),
|
||||
(
|
||||
'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"]',
|
||||
'<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Final Notice - Downgrade Tomorrow</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="background-color: #c62828; padding: 30px; text-align: center;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">FINAL NOTICE</h1>
|
||||
<p style="color: #ffffff; margin: 10px 0 0 0; font-size: 16px;">Downgrade Tomorrow</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
|
||||
<div style="background-color: #ffebee; border: 2px solid #c62828; border-radius: 4px; padding: 20px; margin: 20px 0;">
|
||||
<p style="color: #b71c1c; font-size: 18px; font-weight: bold; margin: 0 0 10px 0;">FINAL NOTICE</p>
|
||||
<p style="color: #333333; font-size: 16px; margin: 0;">Your <strong>{{tier}}</strong> subscription will be downgraded to the free tier <strong>tomorrow</strong> if payment is not received.</p>
|
||||
</div>
|
||||
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
|
||||
<tr>
|
||||
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #c62828;">
|
||||
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Amount Due:</strong> ${{amount}}</p>
|
||||
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Grace Period Ends:</strong> {{gracePeriodEnd}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 20px 0;">This is your last chance to update your payment method and keep your premium features.</p>
|
||||
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 4px; margin: 20px 0;">
|
||||
<p style="color: #333333; font-size: 14px; font-weight: bold; margin: 0 0 10px 0;">After downgrade:</p>
|
||||
<ul style="color: #666666; font-size: 14px; line-height: 1.8; margin: 0; padding-left: 20px;">
|
||||
<li>Access to premium features will be lost</li>
|
||||
<li>Data remains safe but with reduced vehicle limits</li>
|
||||
<li>You can resubscribe at any time</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="https://app.motovaultpro.com/settings/billing" style="display: inline-block; padding: 14px 28px; background-color: #c62828; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: bold; font-size: 16px;">Update Payment Now</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
|
||||
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>'
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
};
|
||||
@@ -599,6 +599,25 @@ export class SubscriptionsService {
|
||||
return 'free';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoices for a user's subscription
|
||||
*/
|
||||
async getInvoices(userId: string): Promise<any[]> {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -323,4 +323,32 @@ export class StripeClient {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List invoices for a customer
|
||||
*/
|
||||
async listInvoices(customerId: string): Promise<any[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,3 +45,4 @@ export { SubscriptionsService } from './domain/subscriptions.service';
|
||||
|
||||
// Routes
|
||||
export { webhooksRoutes } from './api/webhooks.routes';
|
||||
export { subscriptionsRoutes } from './api/subscriptions.routes';
|
||||
|
||||
132
backend/src/features/subscriptions/jobs/grace-period.job.ts
Normal file
132
backend/src/features/subscriptions/jobs/grace-period.job.ts
Normal file
@@ -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<GracePeriodResult> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user