feat: add subscriptions feature capsule - M1 database schema and Stripe client (refs #55)
- Create 4 new tables: subscriptions, subscription_events, donations, tier_vehicle_selections - Add StripeClient wrapper with createCustomer, createSubscription, cancelSubscription, updatePaymentMethod, createPaymentIntent, constructWebhookEvent methods - Implement SubscriptionsRepository with full CRUD and mapRow case conversion - Add domain types for all subscription entities - Install stripe npm package v20.2.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
326
backend/src/features/subscriptions/external/stripe/stripe.client.ts
vendored
Normal file
326
backend/src/features/subscriptions/external/stripe/stripe.client.ts
vendored
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user