feat: add subscriptions service layer and webhook endpoint - M2 (refs #55)
- Implement SubscriptionsService with getSubscription, createSubscription, upgradeSubscription, cancelSubscription, reactivateSubscription - Add handleWebhookEvent for Stripe webhook processing with idempotency - Handle 5 webhook events: subscription.created/updated/deleted, invoice.payment_succeeded/failed - Auto-sync tier changes to user_profiles.subscription_tier - Add public webhook endpoint POST /api/webhooks/stripe (signature verified) - Implement 30-day grace period on payment failure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,13 +54,39 @@ Defined in `user_profiles.subscription_tier` enum:
|
||||
- `canceled` - Subscription canceled
|
||||
- `unpaid` - Payment failed, grace period expired
|
||||
|
||||
## Milestone 2: Service Layer + Webhook Endpoint (COMPLETE)
|
||||
|
||||
### Service Layer
|
||||
- **SubscriptionsService** (`domain/subscriptions.service.ts`): Business logic for subscription management
|
||||
- Get current subscription for user
|
||||
- Create new subscription (Stripe customer + free tier record)
|
||||
- Upgrade subscription (create Stripe subscription with payment method)
|
||||
- Cancel subscription (schedule for end of period)
|
||||
- Reactivate subscription (remove pending cancellation)
|
||||
- Handle Stripe webhook events with idempotency
|
||||
|
||||
### Webhook Processing
|
||||
- **Webhook Events Handled**:
|
||||
- `customer.subscription.created` - Update subscription record with Stripe subscription ID
|
||||
- `customer.subscription.updated` - Update status, tier, period dates
|
||||
- `customer.subscription.deleted` - Mark as canceled, downgrade to free tier
|
||||
- `invoice.payment_succeeded` - Clear grace period, mark active
|
||||
- `invoice.payment_failed` - Set 30-day grace period
|
||||
|
||||
### API Endpoints
|
||||
- **WebhooksController** (`api/webhooks.controller.ts`): Webhook event handler
|
||||
- **Routes** (`api/webhooks.routes.ts`): PUBLIC endpoint with rawBody support
|
||||
- POST /api/webhooks/stripe - Stripe webhook receiver (no JWT auth, signature verified)
|
||||
|
||||
### Integration
|
||||
- Syncs subscription tier changes to `user_profiles.subscription_tier` via UserProfileRepository
|
||||
- Uses environment variables for Stripe price IDs (PRO/ENTERPRISE, MONTHLY/YEARLY)
|
||||
|
||||
## Next Steps (Future Milestones)
|
||||
|
||||
- M2: Subscription service layer with business logic
|
||||
- M3: API endpoints for subscription management
|
||||
- M4: Webhook handlers for Stripe events
|
||||
- M5: Frontend integration and subscription UI
|
||||
- M6: Testing and documentation
|
||||
- M3: API endpoints for subscription management (user-facing CRUD)
|
||||
- M4: Frontend integration and subscription UI
|
||||
- M5: Testing and documentation
|
||||
|
||||
## Database Migration
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @ai-summary Webhook controller for Stripe events
|
||||
* @ai-context Handles incoming Stripe webhook events with signature verification
|
||||
*/
|
||||
|
||||
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 WebhooksController {
|
||||
private service: SubscriptionsService;
|
||||
|
||||
constructor() {
|
||||
const repository = new SubscriptionsRepository(pool);
|
||||
const stripeClient = new StripeClient();
|
||||
this.service = new SubscriptionsService(repository, stripeClient, pool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Stripe webhook events
|
||||
* POST /api/webhooks/stripe
|
||||
*/
|
||||
async handleStripeWebhook(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
// Get raw body from request (must be enabled via config: { rawBody: true })
|
||||
const rawBody = (request as any).rawBody;
|
||||
if (!rawBody) {
|
||||
logger.error('Missing raw body in webhook request');
|
||||
return reply.status(400).send({ error: 'Missing raw body' });
|
||||
}
|
||||
|
||||
// Get Stripe signature from headers
|
||||
const signature = request.headers['stripe-signature'];
|
||||
if (!signature || typeof signature !== 'string') {
|
||||
logger.error('Missing or invalid Stripe signature');
|
||||
return reply.status(400).send({ error: 'Missing Stripe signature' });
|
||||
}
|
||||
|
||||
// Process the webhook event
|
||||
await this.service.handleWebhookEvent(rawBody, signature);
|
||||
|
||||
// Return 200 to acknowledge receipt
|
||||
return reply.status(200).send({ received: true });
|
||||
} catch (error: any) {
|
||||
logger.error('Webhook handler error', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
|
||||
// Return 400 for signature verification failures
|
||||
if (error.message.includes('signature') || error.message.includes('verify')) {
|
||||
return reply.status(400).send({ error: 'Invalid signature' });
|
||||
}
|
||||
|
||||
// Return 500 for other errors
|
||||
return reply.status(500).send({ error: 'Webhook processing failed' });
|
||||
}
|
||||
}
|
||||
}
|
||||
24
backend/src/features/subscriptions/api/webhooks.routes.ts
Normal file
24
backend/src/features/subscriptions/api/webhooks.routes.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @ai-summary Webhook routes for Stripe events
|
||||
* @ai-context PUBLIC endpoint - no JWT auth, authenticated via Stripe signature
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { WebhooksController } from './webhooks.controller';
|
||||
|
||||
export const webhooksRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const controller = new WebhooksController();
|
||||
|
||||
// POST /api/webhooks/stripe - PUBLIC endpoint (no JWT auth)
|
||||
// Stripe authenticates via webhook signature verification
|
||||
// IMPORTANT: rawBody MUST be enabled for signature verification to work
|
||||
fastify.post(
|
||||
'/webhooks/stripe',
|
||||
{
|
||||
config: {
|
||||
rawBody: true, // Enable raw body for Stripe signature verification
|
||||
},
|
||||
},
|
||||
controller.handleStripeWebhook.bind(controller)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,622 @@
|
||||
/**
|
||||
* @ai-summary Subscription business logic and webhook handling
|
||||
* @ai-context Manages subscription lifecycle, Stripe integration, and tier syncing
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { SubscriptionsRepository } from '../data/subscriptions.repository';
|
||||
import { StripeClient } from '../external/stripe/stripe.client';
|
||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||
import {
|
||||
Subscription,
|
||||
SubscriptionResponse,
|
||||
SubscriptionTier,
|
||||
BillingCycle,
|
||||
SubscriptionStatus,
|
||||
UpdateSubscriptionData,
|
||||
} from './subscriptions.types';
|
||||
|
||||
interface StripeWebhookEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
data: {
|
||||
object: any;
|
||||
};
|
||||
}
|
||||
|
||||
export class SubscriptionsService {
|
||||
private userProfileRepository: UserProfileRepository;
|
||||
|
||||
constructor(
|
||||
private repository: SubscriptionsRepository,
|
||||
private stripeClient: StripeClient,
|
||||
pool: Pool
|
||||
) {
|
||||
this.userProfileRepository = new UserProfileRepository(pool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current subscription for user
|
||||
*/
|
||||
async getSubscription(userId: string): Promise<SubscriptionResponse | null> {
|
||||
try {
|
||||
const subscription = await this.repository.findByUserId(userId);
|
||||
|
||||
if (!subscription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapToResponse(subscription);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get subscription', {
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new subscription (Stripe customer + initial free tier record)
|
||||
*/
|
||||
async createSubscription(userId: string, email: string): Promise<Subscription> {
|
||||
try {
|
||||
logger.info('Creating subscription', { userId, email });
|
||||
|
||||
// Check if user already has a subscription
|
||||
const existing = await this.repository.findByUserId(userId);
|
||||
if (existing) {
|
||||
logger.warn('User already has a subscription', { userId, subscriptionId: existing.id });
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create Stripe customer
|
||||
const stripeCustomer = await this.stripeClient.createCustomer(email);
|
||||
|
||||
// Create subscription record with free tier
|
||||
const subscription = await this.repository.create({
|
||||
userId,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
tier: 'free',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
|
||||
logger.info('Subscription created', {
|
||||
subscriptionId: subscription.id,
|
||||
userId,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
});
|
||||
|
||||
return subscription;
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to create subscription', {
|
||||
userId,
|
||||
email,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade from current tier to new tier
|
||||
*/
|
||||
async upgradeSubscription(
|
||||
userId: string,
|
||||
newTier: 'pro' | 'enterprise',
|
||||
billingCycle: 'monthly' | 'yearly',
|
||||
paymentMethodId: string
|
||||
): Promise<Subscription> {
|
||||
try {
|
||||
logger.info('Upgrading subscription', { userId, newTier, billingCycle });
|
||||
|
||||
// Get current subscription
|
||||
const currentSubscription = await this.repository.findByUserId(userId);
|
||||
if (!currentSubscription) {
|
||||
throw new Error('No subscription found for user');
|
||||
}
|
||||
|
||||
// Determine price ID from environment variables
|
||||
const priceId = this.getPriceId(newTier, billingCycle);
|
||||
|
||||
// Create or update Stripe subscription
|
||||
const stripeSubscription = await this.stripeClient.createSubscription(
|
||||
currentSubscription.stripeCustomerId,
|
||||
priceId,
|
||||
paymentMethodId
|
||||
);
|
||||
|
||||
// Update subscription record
|
||||
const updateData: UpdateSubscriptionData = {
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
tier: newTier,
|
||||
billingCycle,
|
||||
status: this.mapStripeStatus(stripeSubscription.status),
|
||||
currentPeriodStart: new Date(stripeSubscription.currentPeriodStart * 1000),
|
||||
currentPeriodEnd: new Date(stripeSubscription.currentPeriodEnd * 1000),
|
||||
cancelAtPeriodEnd: false,
|
||||
};
|
||||
|
||||
const updatedSubscription = await this.repository.update(
|
||||
currentSubscription.id,
|
||||
updateData
|
||||
);
|
||||
|
||||
if (!updatedSubscription) {
|
||||
throw new Error('Failed to update subscription');
|
||||
}
|
||||
|
||||
// Sync tier to user profile
|
||||
await this.syncTierToUserProfile(userId, newTier);
|
||||
|
||||
logger.info('Subscription upgraded', {
|
||||
subscriptionId: updatedSubscription.id,
|
||||
userId,
|
||||
newTier,
|
||||
billingCycle,
|
||||
});
|
||||
|
||||
return updatedSubscription;
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to upgrade subscription', {
|
||||
userId,
|
||||
newTier,
|
||||
billingCycle,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel subscription (schedules for end of period)
|
||||
*/
|
||||
async cancelSubscription(userId: string): Promise<Subscription> {
|
||||
try {
|
||||
logger.info('Canceling subscription', { userId });
|
||||
|
||||
// Get current subscription
|
||||
const currentSubscription = await this.repository.findByUserId(userId);
|
||||
if (!currentSubscription) {
|
||||
throw new Error('No subscription found for user');
|
||||
}
|
||||
|
||||
if (!currentSubscription.stripeSubscriptionId) {
|
||||
throw new Error('No active Stripe subscription to cancel');
|
||||
}
|
||||
|
||||
// Cancel at period end in Stripe
|
||||
await this.stripeClient.cancelSubscription(
|
||||
currentSubscription.stripeSubscriptionId,
|
||||
true
|
||||
);
|
||||
|
||||
// Update subscription record
|
||||
const updatedSubscription = await this.repository.update(currentSubscription.id, {
|
||||
cancelAtPeriodEnd: true,
|
||||
});
|
||||
|
||||
if (!updatedSubscription) {
|
||||
throw new Error('Failed to update subscription');
|
||||
}
|
||||
|
||||
logger.info('Subscription canceled', {
|
||||
subscriptionId: updatedSubscription.id,
|
||||
userId,
|
||||
});
|
||||
|
||||
return updatedSubscription;
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to cancel subscription', {
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate a pending cancellation
|
||||
*/
|
||||
async reactivateSubscription(userId: string): Promise<Subscription> {
|
||||
try {
|
||||
logger.info('Reactivating subscription', { userId });
|
||||
|
||||
// Get current subscription
|
||||
const currentSubscription = await this.repository.findByUserId(userId);
|
||||
if (!currentSubscription) {
|
||||
throw new Error('No subscription found for user');
|
||||
}
|
||||
|
||||
if (!currentSubscription.stripeSubscriptionId) {
|
||||
throw new Error('No active Stripe subscription to reactivate');
|
||||
}
|
||||
|
||||
if (!currentSubscription.cancelAtPeriodEnd) {
|
||||
logger.warn('Subscription is not pending cancellation', {
|
||||
subscriptionId: currentSubscription.id,
|
||||
userId,
|
||||
});
|
||||
return currentSubscription;
|
||||
}
|
||||
|
||||
// Reactivate in Stripe (remove cancel_at_period_end flag)
|
||||
await this.stripeClient.cancelSubscription(
|
||||
currentSubscription.stripeSubscriptionId,
|
||||
false
|
||||
);
|
||||
|
||||
// Update subscription record
|
||||
const updatedSubscription = await this.repository.update(currentSubscription.id, {
|
||||
cancelAtPeriodEnd: false,
|
||||
});
|
||||
|
||||
if (!updatedSubscription) {
|
||||
throw new Error('Failed to update subscription');
|
||||
}
|
||||
|
||||
logger.info('Subscription reactivated', {
|
||||
subscriptionId: updatedSubscription.id,
|
||||
userId,
|
||||
});
|
||||
|
||||
return updatedSubscription;
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to reactivate subscription', {
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming Stripe webhook event
|
||||
*/
|
||||
async handleWebhookEvent(payload: Buffer, signature: string): Promise<void> {
|
||||
try {
|
||||
// Construct and verify webhook event
|
||||
const event = this.stripeClient.constructWebhookEvent(
|
||||
payload,
|
||||
signature
|
||||
) as StripeWebhookEvent;
|
||||
|
||||
logger.info('Processing webhook event', {
|
||||
eventId: event.id,
|
||||
eventType: event.type,
|
||||
});
|
||||
|
||||
// Check idempotency - skip if we've already processed this event
|
||||
const existingEvent = await this.repository.findEventByStripeId(event.id);
|
||||
if (existingEvent) {
|
||||
logger.info('Event already processed, skipping', { eventId: event.id });
|
||||
return;
|
||||
}
|
||||
|
||||
// Process based on event type
|
||||
switch (event.type) {
|
||||
case 'customer.subscription.created':
|
||||
await this.handleSubscriptionCreated(event);
|
||||
break;
|
||||
case 'customer.subscription.updated':
|
||||
await this.handleSubscriptionUpdated(event);
|
||||
break;
|
||||
case 'customer.subscription.deleted':
|
||||
await this.handleSubscriptionDeleted(event);
|
||||
break;
|
||||
case 'invoice.payment_succeeded':
|
||||
await this.handlePaymentSucceeded(event);
|
||||
break;
|
||||
case 'invoice.payment_failed':
|
||||
await this.handlePaymentFailed(event);
|
||||
break;
|
||||
default:
|
||||
logger.info('Unhandled webhook event type', { eventType: event.type });
|
||||
}
|
||||
|
||||
logger.info('Webhook event processed', { eventId: event.id, eventType: event.type });
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to handle webhook event', {
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Private Helper Methods ==========
|
||||
|
||||
/**
|
||||
* Handle customer.subscription.created webhook
|
||||
*/
|
||||
private async handleSubscriptionCreated(event: StripeWebhookEvent): Promise<void> {
|
||||
const stripeSubscription = event.data.object;
|
||||
|
||||
// Find subscription by Stripe customer ID
|
||||
const subscription = await this.repository.findByStripeCustomerId(
|
||||
stripeSubscription.customer
|
||||
);
|
||||
|
||||
if (!subscription) {
|
||||
logger.warn('Subscription not found for Stripe customer', {
|
||||
stripeCustomerId: stripeSubscription.customer,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Update subscription with Stripe subscription ID
|
||||
await this.repository.update(subscription.id, {
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
status: this.mapStripeStatus(stripeSubscription.status),
|
||||
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
|
||||
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
|
||||
});
|
||||
|
||||
// Log event
|
||||
await this.repository.createEvent({
|
||||
subscriptionId: subscription.id,
|
||||
stripeEventId: event.id,
|
||||
eventType: event.type,
|
||||
payload: event.data.object,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle customer.subscription.updated webhook
|
||||
*/
|
||||
private async handleSubscriptionUpdated(event: StripeWebhookEvent): Promise<void> {
|
||||
const stripeSubscription = event.data.object;
|
||||
|
||||
// Find subscription by Stripe subscription ID
|
||||
const subscription = await this.repository.findByStripeSubscriptionId(
|
||||
stripeSubscription.id
|
||||
);
|
||||
|
||||
if (!subscription) {
|
||||
logger.warn('Subscription not found for Stripe subscription', {
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine tier from price metadata or plan
|
||||
const tier = this.determineTierFromStripeSubscription(stripeSubscription);
|
||||
|
||||
// Update subscription
|
||||
const updateData: UpdateSubscriptionData = {
|
||||
status: this.mapStripeStatus(stripeSubscription.status),
|
||||
tier,
|
||||
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
|
||||
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
|
||||
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end || false,
|
||||
};
|
||||
|
||||
await this.repository.update(subscription.id, updateData);
|
||||
|
||||
// Sync tier to user profile
|
||||
await this.syncTierToUserProfile(subscription.userId, tier);
|
||||
|
||||
// Log event
|
||||
await this.repository.createEvent({
|
||||
subscriptionId: subscription.id,
|
||||
stripeEventId: event.id,
|
||||
eventType: event.type,
|
||||
payload: event.data.object,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle customer.subscription.deleted webhook
|
||||
*/
|
||||
private async handleSubscriptionDeleted(event: StripeWebhookEvent): Promise<void> {
|
||||
const stripeSubscription = event.data.object;
|
||||
|
||||
// Find subscription by Stripe subscription ID
|
||||
const subscription = await this.repository.findByStripeSubscriptionId(
|
||||
stripeSubscription.id
|
||||
);
|
||||
|
||||
if (!subscription) {
|
||||
logger.warn('Subscription not found for Stripe subscription', {
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Update subscription to canceled
|
||||
await this.repository.update(subscription.id, {
|
||||
status: 'canceled',
|
||||
});
|
||||
|
||||
// Downgrade tier to free
|
||||
await this.syncTierToUserProfile(subscription.userId, 'free');
|
||||
|
||||
// Log event
|
||||
await this.repository.createEvent({
|
||||
subscriptionId: subscription.id,
|
||||
stripeEventId: event.id,
|
||||
eventType: event.type,
|
||||
payload: event.data.object,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle invoice.payment_succeeded webhook
|
||||
*/
|
||||
private async handlePaymentSucceeded(event: StripeWebhookEvent): Promise<void> {
|
||||
const invoice = event.data.object;
|
||||
|
||||
// Find subscription by Stripe subscription ID
|
||||
const subscription = await this.repository.findByStripeSubscriptionId(
|
||||
invoice.subscription
|
||||
);
|
||||
|
||||
if (!subscription) {
|
||||
logger.warn('Subscription not found for Stripe subscription', {
|
||||
stripeSubscriptionId: invoice.subscription,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear grace period and mark as active
|
||||
await this.repository.update(subscription.id, {
|
||||
status: 'active',
|
||||
gracePeriodEnd: undefined,
|
||||
});
|
||||
|
||||
// Log event
|
||||
await this.repository.createEvent({
|
||||
subscriptionId: subscription.id,
|
||||
stripeEventId: event.id,
|
||||
eventType: event.type,
|
||||
payload: event.data.object,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle invoice.payment_failed webhook
|
||||
*/
|
||||
private async handlePaymentFailed(event: StripeWebhookEvent): Promise<void> {
|
||||
const invoice = event.data.object;
|
||||
|
||||
// Find subscription by Stripe subscription ID
|
||||
const subscription = await this.repository.findByStripeSubscriptionId(
|
||||
invoice.subscription
|
||||
);
|
||||
|
||||
if (!subscription) {
|
||||
logger.warn('Subscription not found for Stripe subscription', {
|
||||
stripeSubscriptionId: invoice.subscription,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Set grace period (30 days from now)
|
||||
const gracePeriodEnd = new Date();
|
||||
gracePeriodEnd.setDate(gracePeriodEnd.getDate() + 30);
|
||||
|
||||
await this.repository.update(subscription.id, {
|
||||
status: 'past_due',
|
||||
gracePeriodEnd,
|
||||
});
|
||||
|
||||
// Log event
|
||||
await this.repository.createEvent({
|
||||
subscriptionId: subscription.id,
|
||||
stripeEventId: event.id,
|
||||
eventType: event.type,
|
||||
payload: event.data.object,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync subscription tier to user_profiles table
|
||||
*/
|
||||
private async syncTierToUserProfile(userId: string, tier: SubscriptionTier): Promise<void> {
|
||||
try {
|
||||
await this.userProfileRepository.updateSubscriptionTier(userId, tier);
|
||||
logger.info('Subscription tier synced to user profile', { userId, tier });
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to sync tier to user profile', {
|
||||
userId,
|
||||
tier,
|
||||
error: error.message,
|
||||
});
|
||||
// Don't throw - we don't want to fail the subscription operation if sync fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Stripe price ID from environment variables
|
||||
*/
|
||||
private getPriceId(tier: 'pro' | 'enterprise', billingCycle: BillingCycle): string {
|
||||
const envVarMap: Record<string, string> = {
|
||||
'pro-monthly': 'STRIPE_PRO_MONTHLY_PRICE_ID',
|
||||
'pro-yearly': 'STRIPE_PRO_YEARLY_PRICE_ID',
|
||||
'enterprise-monthly': 'STRIPE_ENTERPRISE_MONTHLY_PRICE_ID',
|
||||
'enterprise-yearly': 'STRIPE_ENTERPRISE_YEARLY_PRICE_ID',
|
||||
};
|
||||
|
||||
const envVar = envVarMap[`${tier}-${billingCycle}`];
|
||||
const priceId = process.env[envVar];
|
||||
|
||||
if (!priceId) {
|
||||
throw new Error(`Missing environment variable: ${envVar}`);
|
||||
}
|
||||
|
||||
return priceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Stripe subscription status to our status type
|
||||
*/
|
||||
private mapStripeStatus(stripeStatus: string): SubscriptionStatus {
|
||||
switch (stripeStatus) {
|
||||
case 'active':
|
||||
case 'trialing':
|
||||
return 'active';
|
||||
case 'past_due':
|
||||
return 'past_due';
|
||||
case 'canceled':
|
||||
case 'incomplete_expired':
|
||||
return 'canceled';
|
||||
case 'unpaid':
|
||||
return 'unpaid';
|
||||
default:
|
||||
logger.warn('Unknown Stripe status, defaulting to canceled', { stripeStatus });
|
||||
return 'canceled';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine tier from Stripe subscription object
|
||||
*/
|
||||
private determineTierFromStripeSubscription(stripeSubscription: any): SubscriptionTier {
|
||||
// Try to extract tier from price metadata or plan
|
||||
const priceId = stripeSubscription.items?.data?.[0]?.price?.id;
|
||||
|
||||
if (!priceId) {
|
||||
logger.warn('Could not determine tier from Stripe subscription, defaulting to free');
|
||||
return 'free';
|
||||
}
|
||||
|
||||
// Check environment variables to match price ID to tier
|
||||
if (
|
||||
priceId === process.env.STRIPE_PRO_MONTHLY_PRICE_ID ||
|
||||
priceId === process.env.STRIPE_PRO_YEARLY_PRICE_ID
|
||||
) {
|
||||
return 'pro';
|
||||
}
|
||||
|
||||
if (
|
||||
priceId === process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID ||
|
||||
priceId === process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID
|
||||
) {
|
||||
return 'enterprise';
|
||||
}
|
||||
|
||||
logger.warn('Unknown price ID, defaulting to free', { priceId });
|
||||
return 'free';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map subscription entity to response DTO
|
||||
*/
|
||||
private mapToResponse(subscription: Subscription): SubscriptionResponse {
|
||||
return {
|
||||
id: subscription.id,
|
||||
userId: subscription.userId,
|
||||
stripeCustomerId: subscription.stripeCustomerId,
|
||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
||||
tier: subscription.tier,
|
||||
billingCycle: subscription.billingCycle,
|
||||
status: subscription.status,
|
||||
currentPeriodStart: subscription.currentPeriodStart?.toISOString(),
|
||||
currentPeriodEnd: subscription.currentPeriodEnd?.toISOString(),
|
||||
gracePeriodEnd: subscription.gracePeriodEnd?.toISOString(),
|
||||
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
||||
createdAt: subscription.createdAt.toISOString(),
|
||||
updatedAt: subscription.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -39,3 +39,9 @@ export { StripeClient } from './external/stripe/stripe.client';
|
||||
|
||||
// Repository
|
||||
export { SubscriptionsRepository } from './data/subscriptions.repository';
|
||||
|
||||
// Service
|
||||
export { SubscriptionsService } from './domain/subscriptions.service';
|
||||
|
||||
// Routes
|
||||
export { webhooksRoutes } from './api/webhooks.routes';
|
||||
|
||||
Reference in New Issue
Block a user