feat: add subscription API endpoints and grace period job - M3 (refs #55)

API Endpoints (all authenticated):
- GET /api/subscriptions - current subscription status
- POST /api/subscriptions/checkout - create Stripe subscription
- POST /api/subscriptions/cancel - schedule cancellation at period end
- POST /api/subscriptions/reactivate - cancel pending cancellation
- PUT /api/subscriptions/payment-method - update payment method
- GET /api/subscriptions/invoices - billing history

Grace Period Job:
- Daily cron at 2:30 AM to check expired grace periods
- Downgrades to free tier when 30-day grace period expires
- Syncs tier to user_profiles.subscription_tier

Email Templates:
- payment_failed_immediate (first failure)
- payment_failed_7day (7 days before grace ends)
- payment_failed_1day (1 day before grace ends)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-18 16:16:58 -06:00
parent 7a0c09b83f
commit e7461a4836
8 changed files with 744 additions and 1 deletions

View File

@@ -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 {

View File

@@ -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>'
);

View File

@@ -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,
});
}
}
}

View File

@@ -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)
});
};

View File

@@ -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
*/

View File

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

View File

@@ -45,3 +45,4 @@ export { SubscriptionsService } from './domain/subscriptions.service';
// Routes
export { webhooksRoutes } from './api/webhooks.routes';
export { subscriptionsRoutes } from './api/subscriptions.routes';

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