mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
✨ 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:
parent
0015bd0892
commit
ae30ce3323
20 changed files with 1495 additions and 48 deletions
|
|
@ -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_
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
135
services/mana-core-auth/src/db/schema/subscriptions.schema.ts
Normal file
135
services/mana-core-auth/src/db/schema/subscriptions.schema.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
2
services/mana-core-auth/src/stripe/index.ts
Normal file
2
services/mana-core-auth/src/stripe/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './stripe.module';
|
||||
export * from './stripe.service';
|
||||
185
services/mana-core-auth/src/stripe/stripe-webhook.controller.ts
Normal file
185
services/mana-core-auth/src/stripe/stripe-webhook.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
services/mana-core-auth/src/stripe/stripe.module.ts
Normal file
13
services/mana-core-auth/src/stripe/stripe.module.ts
Normal 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 {}
|
||||
159
services/mana-core-auth/src/stripe/stripe.service.ts
Normal file
159
services/mana-core-auth/src/stripe/stripe.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
2
services/mana-core-auth/src/subscriptions/index.ts
Normal file
2
services/mana-core-auth/src/subscriptions/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './subscriptions.module';
|
||||
export * from './subscriptions.service';
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue