feat: Accept Payments - Stripe Integration with User Tiers (#55) #56
@@ -54,13 +54,39 @@ Defined in `user_profiles.subscription_tier` enum:
|
|||||||
- `canceled` - Subscription canceled
|
- `canceled` - Subscription canceled
|
||||||
- `unpaid` - Payment failed, grace period expired
|
- `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)
|
## Next Steps (Future Milestones)
|
||||||
|
|
||||||
- M2: Subscription service layer with business logic
|
- M3: API endpoints for subscription management (user-facing CRUD)
|
||||||
- M3: API endpoints for subscription management
|
- M4: Frontend integration and subscription UI
|
||||||
- M4: Webhook handlers for Stripe events
|
- M5: Testing and documentation
|
||||||
- M5: Frontend integration and subscription UI
|
|
||||||
- M6: Testing and documentation
|
|
||||||
|
|
||||||
## Database Migration
|
## 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
|
// Repository
|
||||||
export { SubscriptionsRepository } from './data/subscriptions.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