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:
Eric Gullickson
2026-01-18 16:04:11 -06:00
parent 411a569788
commit 88b820b1c3
9 changed files with 1404 additions and 41 deletions

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