feat: Accept Payments - Stripe Integration with User Tiers (#55) #56

Merged
egullickson merged 17 commits from issue-55-stripe-integration into main 2026-01-19 02:52:25 +00:00
5 changed files with 745 additions and 5 deletions
Showing only changes of commit 7a0c09b83f - Show all commits

View File

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

View File

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

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

View File

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

View File

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