mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 09:19:39 +02:00
- Add sepa_debit to payment_method_types for credit purchases - Add sepa_debit to subscription checkout sessions - Handle payment_intent.processing webhook for SEPA status tracking - Add blueprint article analyzing payment options (Stripe vs LSV+/FinTS) SEPA offers lower fees (0.8% vs 1.5%+€0.25) for DACH customers. Payments are confirmed 3-14 days after checkout (bank processing).
447 lines
12 KiB
TypeScript
447 lines
12 KiB
TypeScript
import {
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
BadRequestException,
|
|
Inject,
|
|
forwardRef,
|
|
} from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { eq, and, desc } from 'drizzle-orm';
|
|
import Stripe from 'stripe';
|
|
import { getDb } from '../db/connection';
|
|
import { plans, subscriptions, invoices, users, stripeCustomers } from '../db/schema';
|
|
import { StripeService } from '../stripe/stripe.service';
|
|
|
|
@Injectable()
|
|
export class SubscriptionsService {
|
|
private readonly logger = new Logger(SubscriptionsService.name);
|
|
private stripe: Stripe | null = null;
|
|
|
|
constructor(
|
|
private configService: ConfigService,
|
|
@Inject(forwardRef(() => StripeService))
|
|
private stripeService: StripeService
|
|
) {
|
|
const secretKey = this.configService.get<string>('stripe.secretKey');
|
|
if (secretKey) {
|
|
this.stripe = new Stripe(secretKey, { apiVersion: '2025-02-24.acacia' });
|
|
}
|
|
}
|
|
|
|
private getDb() {
|
|
const databaseUrl = this.configService.get<string>('database.url');
|
|
return getDb(databaseUrl!);
|
|
}
|
|
|
|
// ============================================================================
|
|
// PLANS
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get all active plans
|
|
*/
|
|
async getPlans() {
|
|
const db = this.getDb();
|
|
return db.select().from(plans).where(eq(plans.active, true)).orderBy(plans.sortOrder);
|
|
}
|
|
|
|
/**
|
|
* Get a specific plan by ID
|
|
*/
|
|
async getPlan(planId: string) {
|
|
const db = this.getDb();
|
|
const [plan] = await db.select().from(plans).where(eq(plans.id, planId)).limit(1);
|
|
if (!plan) {
|
|
throw new NotFoundException('Plan not found');
|
|
}
|
|
return plan;
|
|
}
|
|
|
|
// ============================================================================
|
|
// SUBSCRIPTIONS
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get user's current subscription
|
|
*/
|
|
async getCurrentSubscription(userId: string) {
|
|
const db = this.getDb();
|
|
|
|
const [subscription] = await db
|
|
.select({
|
|
subscription: subscriptions,
|
|
plan: plans,
|
|
})
|
|
.from(subscriptions)
|
|
.innerJoin(plans, eq(subscriptions.planId, plans.id))
|
|
.where(and(eq(subscriptions.userId, userId), eq(subscriptions.status, 'active')))
|
|
.limit(1);
|
|
|
|
if (!subscription) {
|
|
// Return default free plan info
|
|
const [freePlan] = await db.select().from(plans).where(eq(plans.isDefault, true)).limit(1);
|
|
|
|
return {
|
|
plan: freePlan || null,
|
|
subscription: null,
|
|
isFreePlan: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
plan: subscription.plan,
|
|
subscription: subscription.subscription,
|
|
isFreePlan: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a Stripe Checkout Session for subscription
|
|
*/
|
|
async createCheckoutSession(
|
|
userId: string,
|
|
planId: string,
|
|
billingInterval: 'month' | 'year' = 'month',
|
|
successUrl: string,
|
|
cancelUrl: string
|
|
) {
|
|
if (!this.stripe) {
|
|
throw new BadRequestException('Stripe is not configured');
|
|
}
|
|
|
|
const db = this.getDb();
|
|
|
|
// Get plan
|
|
const [plan] = await db.select().from(plans).where(eq(plans.id, planId)).limit(1);
|
|
if (!plan) {
|
|
throw new NotFoundException('Plan not found');
|
|
}
|
|
|
|
// Get Stripe price ID
|
|
const stripePriceId =
|
|
billingInterval === 'year' ? plan.stripePriceIdYearly : plan.stripePriceIdMonthly;
|
|
|
|
if (!stripePriceId) {
|
|
throw new BadRequestException(`No Stripe price configured for ${billingInterval} billing`);
|
|
}
|
|
|
|
// Get user
|
|
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
|
if (!user) {
|
|
throw new NotFoundException('User not found');
|
|
}
|
|
|
|
// Get or create Stripe customer
|
|
const stripeCustomerId = await this.stripeService.getOrCreateCustomer(userId, user.email);
|
|
|
|
// Create checkout session
|
|
const session = await this.stripe.checkout.sessions.create({
|
|
customer: stripeCustomerId,
|
|
mode: 'subscription',
|
|
payment_method_types: ['card', 'sepa_debit'],
|
|
line_items: [
|
|
{
|
|
price: stripePriceId,
|
|
quantity: 1,
|
|
},
|
|
],
|
|
success_url: successUrl,
|
|
cancel_url: cancelUrl,
|
|
metadata: {
|
|
userId,
|
|
planId,
|
|
billingInterval,
|
|
},
|
|
subscription_data: {
|
|
metadata: {
|
|
userId,
|
|
planId,
|
|
},
|
|
},
|
|
});
|
|
|
|
this.logger.log('Checkout session created', {
|
|
sessionId: session.id,
|
|
userId,
|
|
planId,
|
|
});
|
|
|
|
return {
|
|
sessionId: session.id,
|
|
url: session.url,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a Stripe Customer Portal session for self-service billing
|
|
*/
|
|
async createPortalSession(userId: string, returnUrl: string) {
|
|
if (!this.stripe) {
|
|
throw new BadRequestException('Stripe is not configured');
|
|
}
|
|
|
|
const db = this.getDb();
|
|
|
|
// Get Stripe customer ID
|
|
const [customer] = await db
|
|
.select()
|
|
.from(stripeCustomers)
|
|
.where(eq(stripeCustomers.userId, userId))
|
|
.limit(1);
|
|
|
|
if (!customer) {
|
|
throw new BadRequestException('No billing account found. Please subscribe to a plan first.');
|
|
}
|
|
|
|
const session = await this.stripe.billingPortal.sessions.create({
|
|
customer: customer.stripeCustomerId,
|
|
return_url: returnUrl,
|
|
});
|
|
|
|
this.logger.log('Portal session created', { userId });
|
|
|
|
return {
|
|
url: session.url,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Cancel subscription (at period end)
|
|
*/
|
|
async cancelSubscription(userId: string) {
|
|
if (!this.stripe) {
|
|
throw new BadRequestException('Stripe is not configured');
|
|
}
|
|
|
|
const db = this.getDb();
|
|
|
|
const [subscription] = await db
|
|
.select()
|
|
.from(subscriptions)
|
|
.where(and(eq(subscriptions.userId, userId), eq(subscriptions.status, 'active')))
|
|
.limit(1);
|
|
|
|
if (!subscription || !subscription.stripeSubscriptionId) {
|
|
throw new NotFoundException('No active subscription found');
|
|
}
|
|
|
|
// Cancel at period end (user keeps access until end of billing period)
|
|
await this.stripe.subscriptions.update(subscription.stripeSubscriptionId, {
|
|
cancel_at_period_end: true,
|
|
});
|
|
|
|
// Update local record
|
|
await db
|
|
.update(subscriptions)
|
|
.set({
|
|
cancelAtPeriodEnd: true,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(subscriptions.id, subscription.id));
|
|
|
|
this.logger.log('Subscription scheduled for cancellation', {
|
|
userId,
|
|
subscriptionId: subscription.id,
|
|
});
|
|
|
|
return { success: true, cancelAtPeriodEnd: true };
|
|
}
|
|
|
|
/**
|
|
* Reactivate a canceled subscription (if still within billing period)
|
|
*/
|
|
async reactivateSubscription(userId: string) {
|
|
if (!this.stripe) {
|
|
throw new BadRequestException('Stripe is not configured');
|
|
}
|
|
|
|
const db = this.getDb();
|
|
|
|
const [subscription] = await db
|
|
.select()
|
|
.from(subscriptions)
|
|
.where(and(eq(subscriptions.userId, userId), eq(subscriptions.cancelAtPeriodEnd, true)))
|
|
.limit(1);
|
|
|
|
if (!subscription || !subscription.stripeSubscriptionId) {
|
|
throw new NotFoundException('No canceled subscription found');
|
|
}
|
|
|
|
// Remove cancellation
|
|
await this.stripe.subscriptions.update(subscription.stripeSubscriptionId, {
|
|
cancel_at_period_end: false,
|
|
});
|
|
|
|
await db
|
|
.update(subscriptions)
|
|
.set({
|
|
cancelAtPeriodEnd: false,
|
|
canceledAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(subscriptions.id, subscription.id));
|
|
|
|
this.logger.log('Subscription reactivated', { userId });
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
// ============================================================================
|
|
// INVOICES
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get user's invoices
|
|
*/
|
|
async getInvoices(userId: string, limit = 20) {
|
|
const db = this.getDb();
|
|
|
|
return db
|
|
.select()
|
|
.from(invoices)
|
|
.where(eq(invoices.userId, userId))
|
|
.orderBy(desc(invoices.createdAt))
|
|
.limit(limit);
|
|
}
|
|
|
|
// ============================================================================
|
|
// WEBHOOK HANDLERS
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Handle Stripe subscription created/updated
|
|
*/
|
|
async handleSubscriptionUpdated(stripeSubscription: Stripe.Subscription) {
|
|
const db = this.getDb();
|
|
const userId = stripeSubscription.metadata?.userId;
|
|
|
|
if (!userId) {
|
|
this.logger.warn('Subscription webhook missing userId in metadata', {
|
|
subscriptionId: stripeSubscription.id,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const planId = stripeSubscription.metadata?.planId;
|
|
|
|
// Check if subscription exists
|
|
const [existing] = await db
|
|
.select()
|
|
.from(subscriptions)
|
|
.where(eq(subscriptions.stripeSubscriptionId, stripeSubscription.id))
|
|
.limit(1);
|
|
|
|
const subscriptionData = {
|
|
userId,
|
|
planId: planId || existing?.planId,
|
|
stripeSubscriptionId: stripeSubscription.id,
|
|
stripeCustomerId: stripeSubscription.customer as string,
|
|
stripePriceId: stripeSubscription.items.data[0]?.price.id,
|
|
status: stripeSubscription.status as any,
|
|
billingInterval: stripeSubscription.items.data[0]?.price.recurring?.interval as any,
|
|
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
|
|
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
|
|
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
|
|
canceledAt: stripeSubscription.canceled_at
|
|
? new Date(stripeSubscription.canceled_at * 1000)
|
|
: null,
|
|
endedAt: stripeSubscription.ended_at ? new Date(stripeSubscription.ended_at * 1000) : null,
|
|
trialStart: stripeSubscription.trial_start
|
|
? new Date(stripeSubscription.trial_start * 1000)
|
|
: null,
|
|
trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null,
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
if (existing) {
|
|
await db.update(subscriptions).set(subscriptionData).where(eq(subscriptions.id, existing.id));
|
|
|
|
this.logger.log('Subscription updated', {
|
|
subscriptionId: existing.id,
|
|
status: stripeSubscription.status,
|
|
});
|
|
} else {
|
|
const [created] = await db
|
|
.insert(subscriptions)
|
|
.values(subscriptionData as any)
|
|
.returning();
|
|
|
|
this.logger.log('Subscription created', {
|
|
subscriptionId: created.id,
|
|
userId,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle Stripe invoice events
|
|
*/
|
|
async handleInvoiceUpdated(stripeInvoice: Stripe.Invoice) {
|
|
const db = this.getDb();
|
|
|
|
// Get user from customer
|
|
const [customer] = await db
|
|
.select()
|
|
.from(stripeCustomers)
|
|
.where(eq(stripeCustomers.stripeCustomerId, stripeInvoice.customer as string))
|
|
.limit(1);
|
|
|
|
if (!customer) {
|
|
this.logger.warn('Invoice webhook: customer not found', {
|
|
stripeCustomerId: stripeInvoice.customer,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Get subscription if exists
|
|
let subscriptionId: string | null = null;
|
|
if (stripeInvoice.subscription) {
|
|
const [sub] = await db
|
|
.select()
|
|
.from(subscriptions)
|
|
.where(eq(subscriptions.stripeSubscriptionId, stripeInvoice.subscription as string))
|
|
.limit(1);
|
|
subscriptionId = sub?.id || null;
|
|
}
|
|
|
|
const invoiceData = {
|
|
userId: customer.userId,
|
|
subscriptionId,
|
|
stripeInvoiceId: stripeInvoice.id,
|
|
stripeCustomerId: stripeInvoice.customer as string,
|
|
number: stripeInvoice.number,
|
|
status: stripeInvoice.status || 'unknown',
|
|
amountDueEuroCents: stripeInvoice.amount_due,
|
|
amountPaidEuroCents: stripeInvoice.amount_paid,
|
|
currency: stripeInvoice.currency,
|
|
hostedInvoiceUrl: stripeInvoice.hosted_invoice_url,
|
|
invoicePdfUrl: stripeInvoice.invoice_pdf,
|
|
periodStart: stripeInvoice.period_start ? new Date(stripeInvoice.period_start * 1000) : null,
|
|
periodEnd: stripeInvoice.period_end ? new Date(stripeInvoice.period_end * 1000) : null,
|
|
dueDate: stripeInvoice.due_date ? new Date(stripeInvoice.due_date * 1000) : null,
|
|
paidAt:
|
|
stripeInvoice.status === 'paid' && stripeInvoice.status_transitions?.paid_at
|
|
? new Date(stripeInvoice.status_transitions.paid_at * 1000)
|
|
: null,
|
|
};
|
|
|
|
// Upsert invoice
|
|
const [existing] = await db
|
|
.select()
|
|
.from(invoices)
|
|
.where(eq(invoices.stripeInvoiceId, stripeInvoice.id))
|
|
.limit(1);
|
|
|
|
if (existing) {
|
|
await db.update(invoices).set(invoiceData).where(eq(invoices.id, existing.id));
|
|
} else {
|
|
await db.insert(invoices).values(invoiceData as any);
|
|
}
|
|
|
|
this.logger.log('Invoice synced', {
|
|
invoiceId: stripeInvoice.id,
|
|
status: stripeInvoice.status,
|
|
});
|
|
}
|
|
}
|