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>
355 lines
10 KiB
TypeScript
355 lines
10 KiB
TypeScript
/**
|
|
* @ai-summary Stripe API client wrapper
|
|
* @ai-context Handles all Stripe API interactions with proper error handling
|
|
*/
|
|
|
|
import Stripe from 'stripe';
|
|
import { logger } from '../../../../core/logging/logger';
|
|
import {
|
|
StripeCustomer,
|
|
StripeSubscription,
|
|
StripePaymentIntent,
|
|
StripeWebhookEvent,
|
|
} from './stripe.types';
|
|
|
|
export class StripeClient {
|
|
private stripe: Stripe;
|
|
|
|
constructor() {
|
|
const apiKey = process.env.STRIPE_SECRET_KEY;
|
|
if (!apiKey) {
|
|
throw new Error('STRIPE_SECRET_KEY environment variable is required');
|
|
}
|
|
|
|
this.stripe = new Stripe(apiKey, {
|
|
apiVersion: '2025-12-15.clover',
|
|
typescript: true,
|
|
});
|
|
|
|
logger.info('Stripe client initialized');
|
|
}
|
|
|
|
/**
|
|
* Create a new Stripe customer
|
|
*/
|
|
async createCustomer(email: string, name?: string): Promise<StripeCustomer> {
|
|
try {
|
|
logger.info('Creating Stripe customer', { email, name });
|
|
|
|
const customer = await this.stripe.customers.create({
|
|
email,
|
|
name,
|
|
metadata: {
|
|
source: 'motovaultpro',
|
|
},
|
|
});
|
|
|
|
logger.info('Stripe customer created', { customerId: customer.id });
|
|
|
|
return {
|
|
id: customer.id,
|
|
email: customer.email || email,
|
|
name: customer.name || undefined,
|
|
created: customer.created,
|
|
metadata: customer.metadata,
|
|
};
|
|
} catch (error: any) {
|
|
logger.error('Failed to create Stripe customer', {
|
|
email,
|
|
error: error.message,
|
|
code: error.code,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new subscription for a customer
|
|
*/
|
|
async createSubscription(
|
|
customerId: string,
|
|
priceId: string,
|
|
paymentMethodId?: string
|
|
): Promise<StripeSubscription> {
|
|
try {
|
|
logger.info('Creating Stripe subscription', { customerId, priceId, paymentMethodId });
|
|
|
|
const subscriptionParams: Stripe.SubscriptionCreateParams = {
|
|
customer: customerId,
|
|
items: [{ price: priceId }],
|
|
payment_behavior: 'default_incomplete',
|
|
payment_settings: {
|
|
save_default_payment_method: 'on_subscription',
|
|
},
|
|
expand: ['latest_invoice.payment_intent'],
|
|
};
|
|
|
|
if (paymentMethodId) {
|
|
subscriptionParams.default_payment_method = paymentMethodId;
|
|
}
|
|
|
|
const subscription = await this.stripe.subscriptions.create(subscriptionParams);
|
|
|
|
logger.info('Stripe subscription created', { subscriptionId: subscription.id });
|
|
|
|
return {
|
|
id: subscription.id,
|
|
customer: subscription.customer as string,
|
|
status: subscription.status as StripeSubscription['status'],
|
|
items: subscription.items,
|
|
currentPeriodStart: (subscription as any).current_period_start || 0,
|
|
currentPeriodEnd: (subscription as any).current_period_end || 0,
|
|
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
canceledAt: subscription.canceled_at || undefined,
|
|
created: subscription.created,
|
|
metadata: subscription.metadata,
|
|
};
|
|
} catch (error: any) {
|
|
logger.error('Failed to create Stripe subscription', {
|
|
customerId,
|
|
priceId,
|
|
error: error.message,
|
|
code: error.code,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel a subscription
|
|
*/
|
|
async cancelSubscription(
|
|
subscriptionId: string,
|
|
cancelAtPeriodEnd: boolean = false
|
|
): Promise<StripeSubscription> {
|
|
try {
|
|
logger.info('Canceling Stripe subscription', { subscriptionId, cancelAtPeriodEnd });
|
|
|
|
let subscription: Stripe.Subscription;
|
|
|
|
if (cancelAtPeriodEnd) {
|
|
// Cancel at period end (schedule cancellation)
|
|
subscription = await this.stripe.subscriptions.update(subscriptionId, {
|
|
cancel_at_period_end: true,
|
|
});
|
|
logger.info('Stripe subscription scheduled for cancellation', { subscriptionId });
|
|
} else {
|
|
// Cancel immediately
|
|
subscription = await this.stripe.subscriptions.cancel(subscriptionId);
|
|
logger.info('Stripe subscription canceled immediately', { subscriptionId });
|
|
}
|
|
|
|
return {
|
|
id: subscription.id,
|
|
customer: subscription.customer as string,
|
|
status: subscription.status as StripeSubscription['status'],
|
|
items: subscription.items,
|
|
currentPeriodStart: (subscription as any).current_period_start || 0,
|
|
currentPeriodEnd: (subscription as any).current_period_end || 0,
|
|
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
canceledAt: subscription.canceled_at || undefined,
|
|
created: subscription.created,
|
|
metadata: subscription.metadata,
|
|
};
|
|
} catch (error: any) {
|
|
logger.error('Failed to cancel Stripe subscription', {
|
|
subscriptionId,
|
|
error: error.message,
|
|
code: error.code,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the payment method for a customer
|
|
*/
|
|
async updatePaymentMethod(customerId: string, paymentMethodId: string): Promise<void> {
|
|
try {
|
|
logger.info('Updating Stripe payment method', { customerId, paymentMethodId });
|
|
|
|
// Attach payment method to customer
|
|
await this.stripe.paymentMethods.attach(paymentMethodId, {
|
|
customer: customerId,
|
|
});
|
|
|
|
// Set as default payment method
|
|
await this.stripe.customers.update(customerId, {
|
|
invoice_settings: {
|
|
default_payment_method: paymentMethodId,
|
|
},
|
|
});
|
|
|
|
logger.info('Stripe payment method updated', { customerId, paymentMethodId });
|
|
} catch (error: any) {
|
|
logger.error('Failed to update Stripe payment method', {
|
|
customerId,
|
|
paymentMethodId,
|
|
error: error.message,
|
|
code: error.code,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a payment intent for one-time donations
|
|
*/
|
|
async createPaymentIntent(amount: number, currency: string = 'usd'): Promise<StripePaymentIntent> {
|
|
try {
|
|
logger.info('Creating Stripe payment intent', { amount, currency });
|
|
|
|
const paymentIntent = await this.stripe.paymentIntents.create({
|
|
amount,
|
|
currency,
|
|
metadata: {
|
|
source: 'motovaultpro',
|
|
type: 'donation',
|
|
},
|
|
});
|
|
|
|
logger.info('Stripe payment intent created', { paymentIntentId: paymentIntent.id });
|
|
|
|
return {
|
|
id: paymentIntent.id,
|
|
amount: paymentIntent.amount,
|
|
currency: paymentIntent.currency,
|
|
status: paymentIntent.status,
|
|
customer: paymentIntent.customer as string | undefined,
|
|
payment_method: paymentIntent.payment_method as string | undefined,
|
|
created: paymentIntent.created,
|
|
metadata: paymentIntent.metadata,
|
|
};
|
|
} catch (error: any) {
|
|
logger.error('Failed to create Stripe payment intent', {
|
|
amount,
|
|
currency,
|
|
error: error.message,
|
|
code: error.code,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Construct and verify a webhook event from Stripe
|
|
*/
|
|
constructWebhookEvent(payload: Buffer, signature: string): StripeWebhookEvent {
|
|
try {
|
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
|
if (!webhookSecret) {
|
|
throw new Error('STRIPE_WEBHOOK_SECRET environment variable is required');
|
|
}
|
|
|
|
const event = this.stripe.webhooks.constructEvent(
|
|
payload,
|
|
signature,
|
|
webhookSecret
|
|
);
|
|
|
|
logger.info('Stripe webhook event verified', { eventId: event.id, type: event.type });
|
|
|
|
return {
|
|
id: event.id,
|
|
type: event.type,
|
|
data: event.data,
|
|
created: event.created,
|
|
};
|
|
} catch (error: any) {
|
|
logger.error('Failed to verify Stripe webhook event', {
|
|
error: error.message,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieve a subscription by ID
|
|
*/
|
|
async getSubscription(subscriptionId: string): Promise<StripeSubscription> {
|
|
try {
|
|
logger.info('Retrieving Stripe subscription', { subscriptionId });
|
|
|
|
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
|
|
|
|
return {
|
|
id: subscription.id,
|
|
customer: subscription.customer as string,
|
|
status: subscription.status as StripeSubscription['status'],
|
|
items: subscription.items,
|
|
currentPeriodStart: (subscription as any).current_period_start || 0,
|
|
currentPeriodEnd: (subscription as any).current_period_end || 0,
|
|
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
canceledAt: subscription.canceled_at || undefined,
|
|
created: subscription.created,
|
|
metadata: subscription.metadata,
|
|
};
|
|
} catch (error: any) {
|
|
logger.error('Failed to retrieve Stripe subscription', {
|
|
subscriptionId,
|
|
error: error.message,
|
|
code: error.code,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieve a customer by ID
|
|
*/
|
|
async getCustomer(customerId: string): Promise<StripeCustomer> {
|
|
try {
|
|
logger.info('Retrieving Stripe customer', { customerId });
|
|
|
|
const customer = await this.stripe.customers.retrieve(customerId);
|
|
|
|
if (customer.deleted) {
|
|
throw new Error('Customer has been deleted');
|
|
}
|
|
|
|
return {
|
|
id: customer.id,
|
|
email: customer.email || '',
|
|
name: customer.name || undefined,
|
|
created: customer.created,
|
|
metadata: customer.metadata,
|
|
};
|
|
} catch (error: any) {
|
|
logger.error('Failed to retrieve Stripe customer', {
|
|
customerId,
|
|
error: error.message,
|
|
code: error.code,
|
|
});
|
|
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;
|
|
}
|
|
}
|
|
}
|