feat(auth): add Stripe credit purchases and subscription management

- Add StripeService for PaymentIntent creation and webhook verification
- Add credit purchase flow (POST /credits/purchase)
- Add stripe_customers table for Stripe customer mapping
- Add subscriptions schema (plans, subscriptions, invoices)
- Add SubscriptionsService with Checkout, Portal, Cancel, Reactivate
- Add subscription plans (Free: 150 Mana, Pro: €9.99, Enterprise: €49.99)
- Handle subscription and invoice webhooks
- Update roadmap with completed tasks

Credit pricing: 1 Mana = 1 Cent (no volume discounts)
This commit is contained in:
Till-JS 2026-02-13 22:21:23 +01:00
parent 0015bd0892
commit ae30ce3323
20 changed files with 1495 additions and 48 deletions

View file

@ -17,18 +17,18 @@
### Vorhandene Features
| Feature | Status | Beschreibung |
| -------------- | ------ | ---------------------------------------------- |
| Dashboard | ✅ | Anpassbare Widgets, Drag & Drop |
| Credits-System | ✅ | Übersicht, Transaktionen, Pakete (ohne Stripe) |
| Teams | ✅ | Team-Verwaltung |
| Organizations | ✅ | Organisations-Verwaltung |
| Settings | ✅ | Benutzereinstellungen |
| Themes | ✅ | Theme-Auswahl |
| Feedback | ✅ | Feedback-Formular |
| Profil | ✅ | Basis-Profil-Ansicht |
| i18n | ✅ | 5 Sprachen (DE, EN, ES, FR, IT) |
| Apps-Übersicht | ✅ | Alle Mana-Apps anzeigen |
| Feature | Status | Beschreibung |
| -------------- | ------ | --------------------------------------------- |
| Dashboard | ✅ | Anpassbare Widgets, Drag & Drop |
| Credits-System | ✅ | Übersicht, Transaktionen, Pakete, Stripe-Kauf |
| Teams | ✅ | Team-Verwaltung |
| Organizations | ✅ | Organisations-Verwaltung |
| Settings | ✅ | Benutzereinstellungen |
| Themes | ✅ | Theme-Auswahl |
| Feedback | ✅ | Feedback-Formular |
| Profil | ✅ | Basis-Profil-Ansicht |
| i18n | ✅ | 5 Sprachen (DE, EN, ES, FR, IT) |
| Apps-Übersicht | ✅ | Alle Mana-Apps anzeigen |
### Dashboard-Widgets (6 Typen)
@ -56,29 +56,40 @@
## Kritische TODOs (Hohe Priorität)
### 1. Stripe-Integration für Credit-Kauf
### 1. Stripe-Integration für Credit-Kauf (ERLEDIGT)
**Problem:** Credit-Kauf zeigt nur Alert statt echtem Checkout
**Status:** Abgeschlossen am 2026-02-13
**Betroffene Datei:** `apps/manacore/apps/web/src/routes/(app)/credits/+page.svelte`
**Implementiert:**
```typescript
// Zeile 93-98: TODO im Code
function handleBuyPackage(pkg: CreditPackage) {
// TODO: Integrate with Stripe
alert(`...Stripe-Integration kommt bald!`);
}
```
- [x] Stripe SDK integrieren (`@stripe/mcp` v17.5.0)
- [x] `StripeService` für PaymentIntent-Erstellung
- [x] `POST /credits/purchase` Endpoint
- [x] Webhook-Handler für `payment_intent.succeeded`/`payment_intent.payment_failed`
- [x] Credit-Gutschrift nach erfolgreicher Zahlung (idempotent)
- [x] Stripe MCP Server eingerichtet (OAuth-basiert)
- [x] Test-Pakete angelegt (Starter, Basic, Pro, Ultra)
**Aufgaben:**
**Credit-Pakete:**
| Paket | Credits | Preis | Hinweis |
| ------- | ------- | ------ | ----------------------- |
| Starter | 100 | €1,00 | 1 Mana = 1 Cent (immer) |
| Basic | 500 | €5,00 | Kein Mengenrabatt |
| Pro | 1.500 | €15,00 | Kein Mengenrabatt |
| Ultra | 5.000 | €50,00 | Kein Mengenrabatt |
> **Preisregel:** 1 Mana = 1 Cent. Keine Rabatte für größere Pakete.
**Dateien:**
- `services/mana-core-auth/src/stripe/` - Stripe-Module
- `services/mana-core-auth/src/credits/credits.service.ts` - Purchase-Methoden
**Noch offen:**
- [ ] Stripe SDK integrieren
- [ ] Checkout Session erstellen (Backend)
- [ ] Webhook für erfolgreiche Zahlungen
- [ ] Credit-Gutschrift nach Zahlung
- [ ] Rechnungs-PDF generieren
**Geschätzter Aufwand:** 2-3 Tage
- [ ] Frontend: Stripe Elements einbinden
---
@ -216,26 +227,45 @@ onDeleteAccount: () => {
---
### 6. Subscription/Plan-Management
### 6. Subscription/Plan-Management (Backend ERLEDIGT)
**Beschreibung:** Verwaltung von Abonnements und Plänen
**Status:** Backend implementiert am 2026-02-13
**Features:**
**Implementiert:**
- Aktuelle Plan-Übersicht (Free, Pro, Enterprise)
- Upgrade/Downgrade Workflow
- Rechnungshistorie
- Zahlungsmethoden verwalten
- Kündigung
- [x] DB-Schema: `subscriptions.plans`, `subscriptions.subscriptions`, `subscriptions.invoices`
- [x] `SubscriptionsService` mit Checkout, Portal, Cancel, Reactivate
- [x] `SubscriptionsController` mit REST-Endpoints
- [x] Stripe Checkout Session für Subscriptions
- [x] Stripe Customer Portal Integration (Self-Service Billing)
- [x] Webhook-Handler für Subscription/Invoice Events
- [x] Pläne angelegt (Free, Pro, Enterprise)
**Aufgaben:**
**Subscription-Pläne:**
- [ ] Plan-Übersicht Seite
- [ ] Stripe Customer Portal Integration
- [ ] Rechnungs-Download
| Plan | Mana/Monat | Monatlich | Jährlich | Features |
| ---------- | ---------- | --------- | -------- | --------------------------------------- |
| Free | 150 | €0 | €0 | Basis-Features, Community Support |
| Pro | 1.500 | €9,99 | €99,90 | Alle Features, Priority Support, API |
| Enterprise | 10.000 | €49,99 | €499,90 | SSO, Audit Logs, SLA, Dedicated Support |
**API-Endpoints:**
```
GET /api/v1/subscriptions/plans # Alle Pläne
GET /api/v1/subscriptions/current # Aktuelles Abo
POST /api/v1/subscriptions/checkout # Stripe Checkout starten
POST /api/v1/subscriptions/portal # Billing Portal öffnen
POST /api/v1/subscriptions/cancel # Kündigen
POST /api/v1/subscriptions/reactivate # Reaktivieren
GET /api/v1/subscriptions/invoices # Rechnungen
```
**Noch offen (Frontend):**
- [ ] Plan-Übersicht Seite im Frontend
- [ ] Plan-Vergleichs-UI
**Geschätzter Aufwand:** 2-3 Tage
- [ ] Stripe Price IDs in DB eintragen (nach Stripe-Setup)
---
@ -384,4 +414,4 @@ Diese Tasks können schnell erledigt werden:
---
_Zuletzt aktualisiert: 2024-12-05_
_Zuletzt aktualisiert: 2026-02-13_

View file

@ -2,5 +2,5 @@ import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig({
dbName: 'manacore',
schemaFilter: ['auth', 'credits', 'referrals', 'public'],
schemaFilter: ['auth', 'credits', 'referrals', 'subscriptions', 'public'],
});

View file

@ -14,6 +14,8 @@ import { ReferralsModule } from './referrals/referrals.module';
import { SettingsModule } from './settings/settings.module';
import { TagsModule } from './tags/tags.module';
import { MeModule } from './me/me.module';
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
import { StripeModule } from './stripe/stripe.module';
import { AnalyticsModule } from './analytics';
import { MetricsModule } from './metrics';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
@ -45,6 +47,8 @@ import { LoggerModule } from './common/logger';
SettingsModule,
TagsModule,
MeModule,
StripeModule,
SubscriptionsModule,
],
providers: [
{

View file

@ -1,11 +1,15 @@
import { Controller, Get, Post, Body, UseGuards, Query, ParseIntPipe, Param } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { CreditsService } from './credits.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
import { UseCreditsDto } from './dto/use-credits.dto';
import { AllocateCreditsDto } from './dto/allocate-credits.dto';
import { PurchaseCreditsDto } from './dto/purchase-credits.dto';
@ApiTags('credits')
@ApiBearerAuth('JWT-auth')
@Controller('credits')
@UseGuards(JwtAuthGuard)
export class CreditsController {
@ -16,6 +20,8 @@ export class CreditsController {
// ============================================================================
@Get('balance')
@ApiOperation({ summary: 'Get current credit balance' })
@ApiResponse({ status: 200, description: 'Returns user credit balance' })
async getBalance(@CurrentUser() user: CurrentUserData) {
return this.creditsService.getBalance(user.userId);
}
@ -40,10 +46,34 @@ export class CreditsController {
}
@Get('packages')
@ApiOperation({ summary: 'Get available credit packages' })
@ApiResponse({ status: 200, description: 'Returns list of active credit packages' })
async getPackages() {
return this.creditsService.getPackages();
}
@Post('purchase')
@ApiOperation({ summary: 'Initiate credit purchase' })
@ApiResponse({
status: 201,
description: 'Returns Stripe PaymentIntent client secret for frontend payment',
})
@ApiResponse({ status: 404, description: 'Package not found' })
async initiatePurchase(@CurrentUser() user: CurrentUserData, @Body() dto: PurchaseCreditsDto) {
return this.creditsService.initiatePurchase(user.userId, dto.packageId);
}
@Get('purchase/:purchaseId')
@ApiOperation({ summary: 'Get purchase status' })
@ApiResponse({ status: 200, description: 'Returns purchase details and status' })
@ApiResponse({ status: 404, description: 'Purchase not found' })
async getPurchaseStatus(
@CurrentUser() user: CurrentUserData,
@Param('purchaseId') purchaseId: string
) {
return this.creditsService.getPurchaseStatus(user.userId, purchaseId);
}
// ============================================================================
// ORGANIZATION / B2B ENDPOINTS
// ============================================================================

View file

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { CreditsController } from './credits.controller';
import { CreditsService } from './credits.service';
import { StripeModule } from '../stripe/stripe.module';
@Module({
imports: [forwardRef(() => StripeModule)],
controllers: [CreditsController],
providers: [CreditsService],
exports: [CreditsService],

View file

@ -4,6 +4,9 @@ import {
NotFoundException,
ConflictException,
ForbiddenException,
Inject,
forwardRef,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, and, sql, desc, sum } from 'drizzle-orm';
@ -18,13 +21,21 @@ import {
creditAllocations,
members,
organizations,
users,
} from '../db/schema';
import { UseCreditsDto } from './dto/use-credits.dto';
import { AllocateCreditsDto } from './dto/allocate-credits.dto';
import { StripeService } from '../stripe/stripe.service';
@Injectable()
export class CreditsService {
constructor(private configService: ConfigService) {}
private readonly logger = new Logger(CreditsService.name);
constructor(
private configService: ConfigService,
@Inject(forwardRef(() => StripeService))
private stripeService: StripeService
) {}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
@ -662,4 +673,274 @@ export class CreditsService {
};
});
}
// ============================================================================
// STRIPE PURCHASE METHODS
// ============================================================================
/**
* Initiate a credit purchase
* Creates a pending purchase record and Stripe PaymentIntent
*/
async initiatePurchase(
userId: string,
packageId: string
): Promise<{
purchaseId: string;
clientSecret: string;
amount: number;
credits: number;
}> {
const db = this.getDb();
// 1. Get package details
const [pkg] = await db
.select()
.from(packages)
.where(and(eq(packages.id, packageId), eq(packages.active, true)))
.limit(1);
if (!pkg) {
throw new NotFoundException('Package not found or inactive');
}
// 2. Get user email for Stripe customer
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user) {
throw new NotFoundException('User not found');
}
// 3. Get or create Stripe customer
const stripeCustomerId = await this.stripeService.getOrCreateCustomer(userId, user.email);
// 4. Create pending purchase record
const [purchase] = await db
.insert(purchases)
.values({
userId,
packageId,
credits: pkg.credits,
priceEuroCents: pkg.priceEuroCents,
stripeCustomerId,
status: 'pending',
})
.returning();
// 5. Create PaymentIntent
const paymentIntent = await this.stripeService.createPaymentIntent(
stripeCustomerId,
pkg.priceEuroCents,
{ userId, packageId, purchaseId: purchase.id }
);
// 6. Update purchase with PaymentIntent ID
await db
.update(purchases)
.set({ stripePaymentIntentId: paymentIntent.id })
.where(eq(purchases.id, purchase.id));
this.logger.log('Purchase initiated', {
purchaseId: purchase.id,
userId,
packageId,
credits: pkg.credits,
amount: pkg.priceEuroCents,
});
return {
purchaseId: purchase.id,
clientSecret: paymentIntent.client_secret!,
amount: pkg.priceEuroCents,
credits: pkg.credits,
};
}
/**
* Complete a purchase after successful payment
* Called from webhook handler - MUST be idempotent
*/
async completePurchase(
paymentIntentId: string
): Promise<{ success: boolean; alreadyProcessed?: boolean; creditsAdded?: number }> {
const db = this.getDb();
return await db.transaction(async (tx) => {
// 1. Find purchase by PaymentIntent ID
const [purchase] = await tx
.select()
.from(purchases)
.where(eq(purchases.stripePaymentIntentId, paymentIntentId))
.for('update')
.limit(1);
if (!purchase) {
throw new NotFoundException('Purchase not found for PaymentIntent');
}
// 2. Idempotency check - already completed?
if (purchase.status === 'completed') {
return { success: true, alreadyProcessed: true };
}
// 3. Validate status transition
if (purchase.status !== 'pending') {
throw new BadRequestException(`Cannot complete purchase in status: ${purchase.status}`);
}
// 4. Get or create user balance
let [balance] = await tx
.select()
.from(balances)
.where(eq(balances.userId, purchase.userId))
.for('update')
.limit(1);
if (!balance) {
// Initialize balance if not exists
const signupBonus = this.configService.get<number>('credits.signupBonus') || 150;
const dailyFreeCredits = this.configService.get<number>('credits.dailyFreeCredits') || 5;
[balance] = await tx
.insert(balances)
.values({
userId: purchase.userId,
balance: 0,
freeCreditsRemaining: signupBonus,
dailyFreeCredits,
lastDailyResetAt: new Date(),
})
.returning();
}
const newBalance = balance.balance + purchase.credits;
const now = new Date();
// 5. Update balance with optimistic locking
const updateResult = await tx
.update(balances)
.set({
balance: newBalance,
totalEarned: balance.totalEarned + purchase.credits,
version: balance.version + 1,
updatedAt: now,
})
.where(and(eq(balances.userId, purchase.userId), eq(balances.version, balance.version)))
.returning();
if (updateResult.length === 0) {
throw new ConflictException('Balance modified concurrently. Retry.');
}
// 6. Update purchase status
await tx
.update(purchases)
.set({
status: 'completed',
completedAt: now,
})
.where(eq(purchases.id, purchase.id));
// 7. Create transaction ledger entry
await tx.insert(transactions).values({
userId: purchase.userId,
type: 'purchase',
status: 'completed',
amount: purchase.credits,
balanceBefore: balance.balance,
balanceAfter: newBalance,
appId: 'stripe',
description: `Credit purchase: ${purchase.credits} credits`,
idempotencyKey: `purchase:${paymentIntentId}`,
completedAt: now,
metadata: {
purchaseId: purchase.id,
packageId: purchase.packageId,
stripePaymentIntentId: paymentIntentId,
priceEuroCents: purchase.priceEuroCents,
},
});
this.logger.log('Purchase completed', {
purchaseId: purchase.id,
userId: purchase.userId,
creditsAdded: purchase.credits,
newBalance,
});
return { success: true, alreadyProcessed: false, creditsAdded: purchase.credits };
});
}
/**
* Mark a purchase as failed
* Called from webhook handler when payment fails
*/
async failPurchase(paymentIntentId: string, failureReason: string): Promise<void> {
const db = this.getDb();
const [purchase] = await db
.select()
.from(purchases)
.where(eq(purchases.stripePaymentIntentId, paymentIntentId))
.limit(1);
if (!purchase) {
this.logger.warn('Purchase not found for failed PaymentIntent', { paymentIntentId });
return;
}
// Only update if still pending
if (purchase.status !== 'pending') {
this.logger.debug('Purchase already processed, skipping failure update', {
purchaseId: purchase.id,
currentStatus: purchase.status,
});
return;
}
await db
.update(purchases)
.set({
status: 'failed',
metadata: {
...((purchase.metadata as Record<string, unknown>) || {}),
failureReason,
failedAt: new Date().toISOString(),
},
})
.where(eq(purchases.id, purchase.id));
this.logger.log('Purchase marked as failed', {
purchaseId: purchase.id,
paymentIntentId,
failureReason,
});
}
/**
* Get purchase status by ID
*/
async getPurchaseStatus(userId: string, purchaseId: string) {
const db = this.getDb();
const [purchase] = await db
.select()
.from(purchases)
.where(and(eq(purchases.id, purchaseId), eq(purchases.userId, userId)))
.limit(1);
if (!purchase) {
throw new NotFoundException('Purchase not found');
}
return {
id: purchase.id,
status: purchase.status,
credits: purchase.credits,
priceEuroCents: purchase.priceEuroCents,
createdAt: purchase.createdAt,
completedAt: purchase.completedAt,
};
}
}

View file

@ -32,6 +32,17 @@ export const transactionStatusEnum = pgEnum('transaction_status', [
'cancelled',
]);
// Stripe customer mapping (for reusing Stripe customers across purchases)
export const stripeCustomers = creditsSchema.table('stripe_customers', {
userId: text('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
stripeCustomerId: text('stripe_customer_id').unique().notNull(),
email: text('email'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// Credit balances (one per user)
export const balances = creditsSchema.table('balances', {
userId: text('user_id')

View file

@ -4,4 +4,5 @@ export * from './credits.schema';
export * from './feedback.schema';
export * from './organizations.schema';
export * from './referrals.schema';
export * from './subscriptions.schema';
export * from './tags.schema';

View file

@ -0,0 +1,135 @@
import {
pgSchema,
uuid,
text,
timestamp,
integer,
boolean,
jsonb,
index,
pgEnum,
} from 'drizzle-orm/pg-core';
import { users } from './auth.schema';
export const subscriptionsSchema = pgSchema('subscriptions');
// Subscription status enum
export const subscriptionStatusEnum = pgEnum('subscription_status', [
'active',
'canceled',
'past_due',
'unpaid',
'trialing',
'incomplete',
'incomplete_expired',
'paused',
]);
// Billing interval enum
export const billingIntervalEnum = pgEnum('billing_interval', ['month', 'year']);
// Subscription plans (Free, Pro, Enterprise etc.)
export const plans = subscriptionsSchema.table('plans', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(), // Free, Pro, Enterprise
description: text('description'),
// Monthly credits included
monthlyCredits: integer('monthly_credits').notNull().default(0),
// Pricing
priceMonthlyEuroCents: integer('price_monthly_euro_cents').notNull().default(0),
priceYearlyEuroCents: integer('price_yearly_euro_cents').notNull().default(0),
// Stripe Price IDs
stripePriceIdMonthly: text('stripe_price_id_monthly'),
stripePriceIdYearly: text('stripe_price_id_yearly'),
stripeProductId: text('stripe_product_id'),
// Features (JSON array of feature strings)
features: jsonb('features').$type<string[]>().default([]),
// Limits
maxTeamMembers: integer('max_team_members'),
maxOrganizations: integer('max_organizations'),
// Meta
isDefault: boolean('is_default').default(false).notNull(),
isEnterprise: boolean('is_enterprise').default(false).notNull(),
active: boolean('active').default(true).notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// User subscriptions
export const subscriptions = subscriptionsSchema.table(
'subscriptions',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
planId: uuid('plan_id')
.references(() => plans.id)
.notNull(),
// Stripe references
stripeSubscriptionId: text('stripe_subscription_id').unique(),
stripeCustomerId: text('stripe_customer_id'),
stripePriceId: text('stripe_price_id'),
// Status
status: subscriptionStatusEnum('status').default('active').notNull(),
billingInterval: billingIntervalEnum('billing_interval').default('month').notNull(),
// Dates
currentPeriodStart: timestamp('current_period_start', { withTimezone: true }),
currentPeriodEnd: timestamp('current_period_end', { withTimezone: true }),
cancelAtPeriodEnd: boolean('cancel_at_period_end').default(false).notNull(),
canceledAt: timestamp('canceled_at', { withTimezone: true }),
endedAt: timestamp('ended_at', { withTimezone: true }),
trialStart: timestamp('trial_start', { withTimezone: true }),
trialEnd: timestamp('trial_end', { withTimezone: true }),
// Meta
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdIdx: index('subscriptions_user_id_idx').on(table.userId),
stripeSubscriptionIdIdx: index('subscriptions_stripe_subscription_id_idx').on(
table.stripeSubscriptionId
),
statusIdx: index('subscriptions_status_idx').on(table.status),
})
);
// Invoices (synced from Stripe)
export const invoices = subscriptionsSchema.table(
'invoices',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
subscriptionId: uuid('subscription_id').references(() => subscriptions.id),
// Stripe references
stripeInvoiceId: text('stripe_invoice_id').unique().notNull(),
stripeCustomerId: text('stripe_customer_id'),
// Invoice details
number: text('number'),
status: text('status').notNull(), // draft, open, paid, void, uncollectible
amountDueEuroCents: integer('amount_due_euro_cents').notNull(),
amountPaidEuroCents: integer('amount_paid_euro_cents').notNull().default(0),
currency: text('currency').default('eur').notNull(),
// URLs
hostedInvoiceUrl: text('hosted_invoice_url'),
invoicePdfUrl: text('invoice_pdf_url'),
// Dates
periodStart: timestamp('period_start', { withTimezone: true }),
periodEnd: timestamp('period_end', { withTimezone: true }),
dueDate: timestamp('due_date', { withTimezone: true }),
paidAt: timestamp('paid_at', { withTimezone: true }),
// Meta
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdIdx: index('invoices_user_id_idx').on(table.userId),
stripeInvoiceIdIdx: index('invoices_stripe_invoice_id_idx').on(table.stripeInvoiceId),
statusIdx: index('invoices_status_idx').on(table.status),
})
);

View file

@ -20,7 +20,9 @@ function normalizeRoute(path: string): string {
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create(AppModule, {
rawBody: true, // Enable raw body for Stripe webhook signature verification
});
const configService = app.get(ConfigService);

View file

@ -0,0 +1,2 @@
export * from './stripe.module';
export * from './stripe.service';

View file

@ -0,0 +1,185 @@
import {
Controller,
Post,
Req,
Headers,
HttpCode,
BadRequestException,
Logger,
Inject,
forwardRef,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiExcludeEndpoint } from '@nestjs/swagger';
import type { Request } from 'express';
import type Stripe from 'stripe';
import { StripeService } from './stripe.service';
import { CreditsService } from '../credits/credits.service';
import { SubscriptionsService } from '../subscriptions/subscriptions.service';
interface RawBodyRequest extends Request {
rawBody?: Buffer;
}
@ApiTags('webhooks')
@Controller('webhooks/stripe')
export class StripeWebhookController {
private readonly logger = new Logger(StripeWebhookController.name);
constructor(
private stripeService: StripeService,
@Inject(forwardRef(() => CreditsService))
private creditsService: CreditsService,
@Inject(forwardRef(() => SubscriptionsService))
private subscriptionsService: SubscriptionsService
) {}
@Post()
@HttpCode(200)
@ApiExcludeEndpoint() // Hide from Swagger - internal webhook
@ApiOperation({ summary: 'Handle Stripe webhooks' })
@ApiResponse({ status: 200, description: 'Webhook processed' })
@ApiResponse({ status: 400, description: 'Invalid webhook signature' })
async handleWebhook(@Req() req: RawBodyRequest, @Headers('stripe-signature') signature: string) {
const rawBody = req.rawBody;
if (!rawBody) {
this.logger.warn('Webhook received without raw body');
throw new BadRequestException('Missing raw body');
}
if (!signature) {
this.logger.warn('Webhook received without signature');
throw new BadRequestException('Missing stripe-signature header');
}
// Verify signature and parse event
let event: Stripe.Event;
try {
event = this.stripeService.verifyWebhookSignature(rawBody, signature);
} catch (err) {
this.logger.warn('Webhook signature verification failed', {
error: err instanceof Error ? err.message : 'Unknown error',
});
throw new BadRequestException('Invalid webhook signature');
}
this.logger.log('Webhook received', {
type: event.type,
id: event.id,
});
// Handle relevant events
switch (event.type) {
// Credit purchases
case 'payment_intent.succeeded':
await this.handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent);
break;
case 'payment_intent.payment_failed':
await this.handlePaymentFailed(event.data.object as Stripe.PaymentIntent);
break;
// Subscriptions
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
// Invoices
case 'invoice.created':
case 'invoice.updated':
case 'invoice.paid':
case 'invoice.payment_failed':
await this.handleInvoiceUpdated(event.data.object as Stripe.Invoice);
break;
default:
this.logger.debug(`Unhandled event type: ${event.type}`);
}
return { received: true };
}
private async handlePaymentSucceeded(paymentIntent: Stripe.PaymentIntent) {
this.logger.log('Processing payment success', {
paymentIntentId: paymentIntent.id,
amount: paymentIntent.amount,
customer: paymentIntent.customer,
});
try {
const result = await this.creditsService.completePurchase(paymentIntent.id);
if (result.alreadyProcessed) {
this.logger.log('Purchase already processed (idempotent)', {
paymentIntentId: paymentIntent.id,
});
} else {
this.logger.log('Purchase completed successfully', {
paymentIntentId: paymentIntent.id,
creditsAdded: result.creditsAdded,
});
}
} catch (error) {
this.logger.error('Failed to complete purchase', {
paymentIntentId: paymentIntent.id,
error: error instanceof Error ? error.message : 'Unknown error',
});
// Rethrow to return 500 to Stripe for retry
throw error;
}
}
private async handlePaymentFailed(paymentIntent: Stripe.PaymentIntent) {
const failureMessage = paymentIntent.last_payment_error?.message || 'Payment failed';
this.logger.log('Processing payment failure', {
paymentIntentId: paymentIntent.id,
failureMessage,
});
try {
await this.creditsService.failPurchase(paymentIntent.id, failureMessage);
} catch (error) {
this.logger.error('Failed to mark purchase as failed', {
paymentIntentId: paymentIntent.id,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
private async handleSubscriptionUpdated(subscription: Stripe.Subscription) {
this.logger.log('Processing subscription update', {
subscriptionId: subscription.id,
status: subscription.status,
});
try {
await this.subscriptionsService.handleSubscriptionUpdated(subscription);
} catch (error) {
this.logger.error('Failed to process subscription update', {
subscriptionId: subscription.id,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
private async handleInvoiceUpdated(invoice: Stripe.Invoice) {
this.logger.log('Processing invoice update', {
invoiceId: invoice.id,
status: invoice.status,
});
try {
await this.subscriptionsService.handleInvoiceUpdated(invoice);
} catch (error) {
this.logger.error('Failed to process invoice update', {
invoiceId: invoice.id,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
}

View file

@ -0,0 +1,13 @@
import { Module, forwardRef } from '@nestjs/common';
import { StripeService } from './stripe.service';
import { StripeWebhookController } from './stripe-webhook.controller';
import { CreditsModule } from '../credits/credits.module';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
@Module({
imports: [forwardRef(() => CreditsModule), forwardRef(() => SubscriptionsModule)],
controllers: [StripeWebhookController],
providers: [StripeService],
exports: [StripeService],
})
export class StripeModule {}

View file

@ -0,0 +1,159 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Stripe from 'stripe';
import { eq } from 'drizzle-orm';
import { getDb } from '../db/connection';
import { stripeCustomers } from '../db/schema';
export interface PaymentIntentMetadata {
userId: string;
packageId: string;
purchaseId: string;
}
@Injectable()
export class StripeService {
private stripe: Stripe | null = null;
private readonly logger = new Logger(StripeService.name);
constructor(private configService: ConfigService) {
const secretKey = this.configService.get<string>('stripe.secretKey');
if (secretKey) {
this.stripe = new Stripe(secretKey, { apiVersion: '2025-02-24.acacia' });
this.logger.log('Stripe client initialized');
} else {
this.logger.warn('Stripe secret key not configured - payment features disabled');
}
}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
private ensureStripeConfigured(): Stripe {
if (!this.stripe) {
throw new ServiceUnavailableException('Stripe is not configured');
}
return this.stripe;
}
/**
* Get or create a Stripe customer for a user
* Caches the customer ID in the stripe_customers table
*/
async getOrCreateCustomer(userId: string, email: string): Promise<string> {
const stripe = this.ensureStripeConfigured();
const db = this.getDb();
// Check if we already have a Stripe customer for this user
const [existing] = await db
.select()
.from(stripeCustomers)
.where(eq(stripeCustomers.userId, userId))
.limit(1);
if (existing) {
return existing.stripeCustomerId;
}
// Create a new Stripe customer
try {
const customer = await stripe.customers.create({
email,
metadata: { userId },
});
// Store the mapping
await db.insert(stripeCustomers).values({
userId,
stripeCustomerId: customer.id,
email,
});
this.logger.log('Created Stripe customer', { userId, customerId: customer.id });
return customer.id;
} catch (error) {
this.logger.error('Failed to create Stripe customer', {
userId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw new ServiceUnavailableException('Failed to create payment customer');
}
}
/**
* Create a PaymentIntent for a credit package purchase
*/
async createPaymentIntent(
customerId: string,
amountCents: number,
metadata: PaymentIntentMetadata
): Promise<Stripe.PaymentIntent> {
const stripe = this.ensureStripeConfigured();
try {
const paymentIntent = await stripe.paymentIntents.create({
amount: amountCents,
currency: 'eur',
customer: customerId,
metadata: {
userId: metadata.userId,
packageId: metadata.packageId,
purchaseId: metadata.purchaseId,
},
automatic_payment_methods: {
enabled: true,
},
});
this.logger.log('Created PaymentIntent', {
paymentIntentId: paymentIntent.id,
amount: amountCents,
customerId,
});
return paymentIntent;
} catch (error) {
this.logger.error('Failed to create PaymentIntent', {
customerId,
amount: amountCents,
error: error instanceof Error ? error.message : 'Unknown error',
});
if (error instanceof Stripe.errors.StripeError) {
throw new ServiceUnavailableException(`Payment service error: ${error.message}`);
}
throw new ServiceUnavailableException('Failed to create payment intent');
}
}
/**
* Verify a Stripe webhook signature and parse the event
*/
verifyWebhookSignature(payload: Buffer, signature: string): Stripe.Event {
const stripe = this.ensureStripeConfigured();
const webhookSecret = this.configService.get<string>('stripe.webhookSecret');
if (!webhookSecret) {
throw new ServiceUnavailableException('Stripe webhook secret not configured');
}
try {
return stripe.webhooks.constructEvent(payload, signature, webhookSecret);
} catch (error) {
this.logger.warn('Webhook signature verification failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
/**
* Retrieve a PaymentIntent by ID (for verification)
*/
async retrievePaymentIntent(paymentIntentId: string): Promise<Stripe.PaymentIntent> {
const stripe = this.ensureStripeConfigured();
return stripe.paymentIntents.retrieve(paymentIntentId);
}
}

View file

@ -0,0 +1,20 @@
import { IsUUID, IsEnum, IsUrl } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateCheckoutSessionDto {
@ApiProperty({ description: 'Plan ID to subscribe to' })
@IsUUID()
planId: string;
@ApiProperty({ enum: ['month', 'year'], default: 'month' })
@IsEnum(['month', 'year'])
billingInterval: 'month' | 'year' = 'month';
@ApiProperty({ description: 'URL to redirect to after successful payment' })
@IsUrl()
successUrl: string;
@ApiProperty({ description: 'URL to redirect to if payment is canceled' })
@IsUrl()
cancelUrl: string;
}

View file

@ -0,0 +1,8 @@
import { IsUrl } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreatePortalSessionDto {
@ApiProperty({ description: 'URL to return to after leaving the portal' })
@IsUrl()
returnUrl: string;
}

View file

@ -0,0 +1,2 @@
export * from './subscriptions.module';
export * from './subscriptions.service';

View file

@ -0,0 +1,103 @@
import { Controller, Get, Post, Body, Param, UseGuards, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { SubscriptionsService } from './subscriptions.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
import { CreateCheckoutSessionDto } from './dto/create-checkout-session.dto';
import { CreatePortalSessionDto } from './dto/create-portal-session.dto';
@ApiTags('subscriptions')
@Controller('subscriptions')
export class SubscriptionsController {
constructor(private readonly subscriptionsService: SubscriptionsService) {}
// ============================================================================
// PUBLIC ENDPOINTS
// ============================================================================
@Get('plans')
@ApiOperation({ summary: 'Get all available subscription plans' })
@ApiResponse({ status: 200, description: 'Returns list of active plans' })
async getPlans() {
return this.subscriptionsService.getPlans();
}
@Get('plans/:planId')
@ApiOperation({ summary: 'Get a specific plan' })
@ApiResponse({ status: 200, description: 'Returns plan details' })
@ApiResponse({ status: 404, description: 'Plan not found' })
async getPlan(@Param('planId') planId: string) {
return this.subscriptionsService.getPlan(planId);
}
// ============================================================================
// PROTECTED ENDPOINTS
// ============================================================================
@Get('current')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get current subscription' })
@ApiResponse({ status: 200, description: 'Returns current subscription and plan' })
async getCurrentSubscription(@CurrentUser() user: CurrentUserData) {
return this.subscriptionsService.getCurrentSubscription(user.userId);
}
@Post('checkout')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create Stripe checkout session for subscription' })
@ApiResponse({ status: 201, description: 'Returns checkout session URL' })
async createCheckoutSession(
@CurrentUser() user: CurrentUserData,
@Body() dto: CreateCheckoutSessionDto
) {
return this.subscriptionsService.createCheckoutSession(
user.userId,
dto.planId,
dto.billingInterval,
dto.successUrl,
dto.cancelUrl
);
}
@Post('portal')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create Stripe Customer Portal session' })
@ApiResponse({ status: 201, description: 'Returns portal URL for billing management' })
async createPortalSession(
@CurrentUser() user: CurrentUserData,
@Body() dto: CreatePortalSessionDto
) {
return this.subscriptionsService.createPortalSession(user.userId, dto.returnUrl);
}
@Post('cancel')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Cancel subscription at period end' })
@ApiResponse({ status: 200, description: 'Subscription scheduled for cancellation' })
async cancelSubscription(@CurrentUser() user: CurrentUserData) {
return this.subscriptionsService.cancelSubscription(user.userId);
}
@Post('reactivate')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Reactivate a canceled subscription' })
@ApiResponse({ status: 200, description: 'Subscription reactivated' })
async reactivateSubscription(@CurrentUser() user: CurrentUserData) {
return this.subscriptionsService.reactivateSubscription(user.userId);
}
@Get('invoices')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get invoice history' })
@ApiResponse({ status: 200, description: 'Returns list of invoices' })
async getInvoices(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) {
return this.subscriptionsService.getInvoices(user.userId, limit);
}
}

View file

@ -0,0 +1,12 @@
import { Module, forwardRef } from '@nestjs/common';
import { SubscriptionsController } from './subscriptions.controller';
import { SubscriptionsService } from './subscriptions.service';
import { StripeModule } from '../stripe/stripe.module';
@Module({
imports: [forwardRef(() => StripeModule)],
controllers: [SubscriptionsController],
providers: [SubscriptionsService],
exports: [SubscriptionsService],
})
export class SubscriptionsModule {}

View file

@ -0,0 +1,447 @@
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'],
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,
});
}
}