From ae30ce33232b5de0b06f8acd0ada1a721c930217 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:21:23 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(auth):=20add=20Stripe=20credit?= =?UTF-8?q?=20purchases=20and=20subscription=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- MANACORE-TODOS.md | 118 +++-- services/mana-core-auth/drizzle.config.ts | 2 +- services/mana-core-auth/src/app.module.ts | 4 + .../src/credits/credits.controller.ts | 30 ++ .../src/credits/credits.module.ts | 4 +- .../src/credits/credits.service.ts | 283 ++++++++++- .../src/db/schema/credits.schema.ts | 11 + .../mana-core-auth/src/db/schema/index.ts | 1 + .../src/db/schema/subscriptions.schema.ts | 135 ++++++ services/mana-core-auth/src/main.ts | 4 +- services/mana-core-auth/src/stripe/index.ts | 2 + .../src/stripe/stripe-webhook.controller.ts | 185 ++++++++ .../src/stripe/stripe.module.ts | 13 + .../src/stripe/stripe.service.ts | 159 +++++++ .../dto/create-checkout-session.dto.ts | 20 + .../dto/create-portal-session.dto.ts | 8 + .../mana-core-auth/src/subscriptions/index.ts | 2 + .../subscriptions/subscriptions.controller.ts | 103 ++++ .../src/subscriptions/subscriptions.module.ts | 12 + .../subscriptions/subscriptions.service.ts | 447 ++++++++++++++++++ 20 files changed, 1495 insertions(+), 48 deletions(-) create mode 100644 services/mana-core-auth/src/db/schema/subscriptions.schema.ts create mode 100644 services/mana-core-auth/src/stripe/index.ts create mode 100644 services/mana-core-auth/src/stripe/stripe-webhook.controller.ts create mode 100644 services/mana-core-auth/src/stripe/stripe.module.ts create mode 100644 services/mana-core-auth/src/stripe/stripe.service.ts create mode 100644 services/mana-core-auth/src/subscriptions/dto/create-checkout-session.dto.ts create mode 100644 services/mana-core-auth/src/subscriptions/dto/create-portal-session.dto.ts create mode 100644 services/mana-core-auth/src/subscriptions/index.ts create mode 100644 services/mana-core-auth/src/subscriptions/subscriptions.controller.ts create mode 100644 services/mana-core-auth/src/subscriptions/subscriptions.module.ts create mode 100644 services/mana-core-auth/src/subscriptions/subscriptions.service.ts diff --git a/MANACORE-TODOS.md b/MANACORE-TODOS.md index ad9544ca3..923685db5 100644 --- a/MANACORE-TODOS.md +++ b/MANACORE-TODOS.md @@ -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_ diff --git a/services/mana-core-auth/drizzle.config.ts b/services/mana-core-auth/drizzle.config.ts index e2027b5f3..2e96a07b2 100644 --- a/services/mana-core-auth/drizzle.config.ts +++ b/services/mana-core-auth/drizzle.config.ts @@ -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'], }); diff --git a/services/mana-core-auth/src/app.module.ts b/services/mana-core-auth/src/app.module.ts index ff80934c1..7da20621e 100644 --- a/services/mana-core-auth/src/app.module.ts +++ b/services/mana-core-auth/src/app.module.ts @@ -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: [ { diff --git a/services/mana-core-auth/src/credits/credits.controller.ts b/services/mana-core-auth/src/credits/credits.controller.ts index 6bdc805b5..085a1c0a2 100644 --- a/services/mana-core-auth/src/credits/credits.controller.ts +++ b/services/mana-core-auth/src/credits/credits.controller.ts @@ -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 // ============================================================================ diff --git a/services/mana-core-auth/src/credits/credits.module.ts b/services/mana-core-auth/src/credits/credits.module.ts index c875eda09..91e731e32 100644 --- a/services/mana-core-auth/src/credits/credits.module.ts +++ b/services/mana-core-auth/src/credits/credits.module.ts @@ -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], diff --git a/services/mana-core-auth/src/credits/credits.service.ts b/services/mana-core-auth/src/credits/credits.service.ts index 4aa46fd19..ad6b41b5a 100644 --- a/services/mana-core-auth/src/credits/credits.service.ts +++ b/services/mana-core-auth/src/credits/credits.service.ts @@ -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('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('credits.signupBonus') || 150; + const dailyFreeCredits = this.configService.get('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 { + 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) || {}), + 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, + }; + } } diff --git a/services/mana-core-auth/src/db/schema/credits.schema.ts b/services/mana-core-auth/src/db/schema/credits.schema.ts index 6a33bacca..6db5bcae4 100644 --- a/services/mana-core-auth/src/db/schema/credits.schema.ts +++ b/services/mana-core-auth/src/db/schema/credits.schema.ts @@ -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') diff --git a/services/mana-core-auth/src/db/schema/index.ts b/services/mana-core-auth/src/db/schema/index.ts index f85eecfc8..1b2674d69 100644 --- a/services/mana-core-auth/src/db/schema/index.ts +++ b/services/mana-core-auth/src/db/schema/index.ts @@ -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'; diff --git a/services/mana-core-auth/src/db/schema/subscriptions.schema.ts b/services/mana-core-auth/src/db/schema/subscriptions.schema.ts new file mode 100644 index 000000000..0016550b1 --- /dev/null +++ b/services/mana-core-auth/src/db/schema/subscriptions.schema.ts @@ -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().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), + }) +); diff --git a/services/mana-core-auth/src/main.ts b/services/mana-core-auth/src/main.ts index e17eeb1eb..e611ae175 100644 --- a/services/mana-core-auth/src/main.ts +++ b/services/mana-core-auth/src/main.ts @@ -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); diff --git a/services/mana-core-auth/src/stripe/index.ts b/services/mana-core-auth/src/stripe/index.ts new file mode 100644 index 000000000..71f3586ab --- /dev/null +++ b/services/mana-core-auth/src/stripe/index.ts @@ -0,0 +1,2 @@ +export * from './stripe.module'; +export * from './stripe.service'; diff --git a/services/mana-core-auth/src/stripe/stripe-webhook.controller.ts b/services/mana-core-auth/src/stripe/stripe-webhook.controller.ts new file mode 100644 index 000000000..9704c17da --- /dev/null +++ b/services/mana-core-auth/src/stripe/stripe-webhook.controller.ts @@ -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; + } + } +} diff --git a/services/mana-core-auth/src/stripe/stripe.module.ts b/services/mana-core-auth/src/stripe/stripe.module.ts new file mode 100644 index 000000000..fcca989ec --- /dev/null +++ b/services/mana-core-auth/src/stripe/stripe.module.ts @@ -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 {} diff --git a/services/mana-core-auth/src/stripe/stripe.service.ts b/services/mana-core-auth/src/stripe/stripe.service.ts new file mode 100644 index 000000000..e8d5cca27 --- /dev/null +++ b/services/mana-core-auth/src/stripe/stripe.service.ts @@ -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('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('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 { + 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 { + 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('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 { + const stripe = this.ensureStripeConfigured(); + return stripe.paymentIntents.retrieve(paymentIntentId); + } +} diff --git a/services/mana-core-auth/src/subscriptions/dto/create-checkout-session.dto.ts b/services/mana-core-auth/src/subscriptions/dto/create-checkout-session.dto.ts new file mode 100644 index 000000000..e272b8307 --- /dev/null +++ b/services/mana-core-auth/src/subscriptions/dto/create-checkout-session.dto.ts @@ -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; +} diff --git a/services/mana-core-auth/src/subscriptions/dto/create-portal-session.dto.ts b/services/mana-core-auth/src/subscriptions/dto/create-portal-session.dto.ts new file mode 100644 index 000000000..7fd4bf2fd --- /dev/null +++ b/services/mana-core-auth/src/subscriptions/dto/create-portal-session.dto.ts @@ -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; +} diff --git a/services/mana-core-auth/src/subscriptions/index.ts b/services/mana-core-auth/src/subscriptions/index.ts new file mode 100644 index 000000000..de0a4aae6 --- /dev/null +++ b/services/mana-core-auth/src/subscriptions/index.ts @@ -0,0 +1,2 @@ +export * from './subscriptions.module'; +export * from './subscriptions.service'; diff --git a/services/mana-core-auth/src/subscriptions/subscriptions.controller.ts b/services/mana-core-auth/src/subscriptions/subscriptions.controller.ts new file mode 100644 index 000000000..cd8f90804 --- /dev/null +++ b/services/mana-core-auth/src/subscriptions/subscriptions.controller.ts @@ -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); + } +} diff --git a/services/mana-core-auth/src/subscriptions/subscriptions.module.ts b/services/mana-core-auth/src/subscriptions/subscriptions.module.ts new file mode 100644 index 000000000..03b46ace4 --- /dev/null +++ b/services/mana-core-auth/src/subscriptions/subscriptions.module.ts @@ -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 {} diff --git a/services/mana-core-auth/src/subscriptions/subscriptions.service.ts b/services/mana-core-auth/src/subscriptions/subscriptions.service.ts new file mode 100644 index 000000000..d3a5648f9 --- /dev/null +++ b/services/mana-core-auth/src/subscriptions/subscriptions.service.ts @@ -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('stripe.secretKey'); + if (secretKey) { + this.stripe = new Stripe(secretKey, { apiVersion: '2025-02-24.acacia' }); + } + } + + private getDb() { + const databaseUrl = this.configService.get('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, + }); + } +}