Compare commits
56 Commits
ci
..
9ffe54ce9f
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ffe54ce9f | |||
| 8c56741908 | |||
| 72a0409c16 | |||
| 1723d6ecef | |||
| 206183670d | |||
| f927c0c205 | |||
| 9fe0a27a60 | |||
| 81fb1a4499 | |||
| e636b8cef8 | |||
| f09e24d52c | |||
| 36603259c6 | |||
| 9ec5a88af9 | |||
| 34f3dbdfc3 | |||
| 8ef30e2a76 | |||
| 50ea4fc44f | |||
| 7b9930596b | |||
| 4ed290ccf3 | |||
| 945b955b54 | |||
| dd1771be1e | |||
| 8c77df494b | |||
| d4a23e8d99 | |||
| 0320219d57 | |||
| dff2ef796b | |||
| 53219e3eaf | |||
| 46cb873190 | |||
| fa958d31f6 | |||
| 916ecef476 | |||
| 754a812d4b | |||
| a7a5674818 | |||
| eef650bf61 | |||
| 32afd5ce47 | |||
| 3c181565e0 | |||
| 95e0a327c4 | |||
| 3899c86b29 | |||
| e74a4d3930 | |||
| 302565dbac | |||
| 4c06953a7a | |||
| fa5fe4bace | |||
| 3ae05a0a2f | |||
| 4ba7babc76 | |||
| 770fbdce24 | |||
| bd70b59c5e | |||
| 613b36be83 | |||
| 5f55692ef0 | |||
| 72f6f8dc33 | |||
| 81cfd6ba24 | |||
| 21a844cb8a | |||
| 18838b5273 | |||
| f7487ee240 | |||
| ffa3540d1a | |||
| 660295e218 | |||
| f28244753f | |||
| 1e68ccd4d0 | |||
| 3f7032260b | |||
| 83e32dc289 | |||
| baee45b861 |
@@ -184,3 +184,9 @@ docs/za-download-3/
|
|||||||
*.docx
|
*.docx
|
||||||
*.xlsx
|
*.xlsx
|
||||||
*.pptx
|
*.pptx
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Entfernte Projekte (nicht mehr aktiv)
|
||||||
|
# ============================================
|
||||||
|
BreakpilotDrive/
|
||||||
|
billing-service/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import './globals.css'
|
|||||||
const inter = Inter({ subsets: ['latin'] })
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'BreakPilot Admin v2',
|
title: 'BreakPilot Admin Lehrer KI',
|
||||||
description: 'Neues Admin-Frontend mit verbesserter Navigation und Rollen-System',
|
description: 'Neues Admin-Frontend mit verbesserter Navigation und Rollen-System',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function Header({ title, description }: HeaderProps) {
|
|||||||
|
|
||||||
{/* User Area */}
|
{/* User Area */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm text-slate-500">Admin v2</span>
|
<span className="text-sm text-slate-500">Admin Lehrer KI</span>
|
||||||
<div className="w-8 h-8 rounded-full bg-primary-600 flex items-center justify-center text-white text-sm font-medium">
|
<div className="w-8 h-8 rounded-full bg-primary-600 flex items-center justify-center text-white text-sm font-medium">
|
||||||
A
|
A
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export function Sidebar({ onRoleChange }: SidebarProps) {
|
|||||||
<div className="h-16 flex items-center justify-between px-4 border-b border-slate-700">
|
<div className="h-16 flex items-center justify-between px-4 border-b border-slate-700">
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<Link href="/dashboard" className="font-bold text-lg">
|
<Link href="/dashboard" className="font-bold text-lg">
|
||||||
Admin v2
|
Admin Lehrer KI
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
+1
-159
@@ -5,7 +5,7 @@
|
|||||||
* All DSGVO and Compliance modules are now consolidated under the SDK.
|
* All DSGVO and Compliance modules are now consolidated under the SDK.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type CategoryId = 'compliance-sdk' | 'ai' | 'infrastructure' | 'education' | 'communication' | 'development' | 'website' | 'sdk-docs'
|
export type CategoryId = 'compliance-sdk' | 'ai' | 'education' | 'website' | 'sdk-docs'
|
||||||
|
|
||||||
export interface NavModule {
|
export interface NavModule {
|
||||||
id: string
|
id: string
|
||||||
@@ -162,67 +162,6 @@ export const navigation: NavCategory[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Infrastruktur & DevOps
|
|
||||||
// =========================================================================
|
|
||||||
{
|
|
||||||
id: 'infrastructure',
|
|
||||||
name: 'Infrastruktur & DevOps',
|
|
||||||
icon: 'server',
|
|
||||||
color: '#f97316', // Orange
|
|
||||||
colorClass: 'infrastructure',
|
|
||||||
description: 'GPU, Security, CI/CD & Monitoring',
|
|
||||||
modules: [
|
|
||||||
{
|
|
||||||
id: 'ci-cd',
|
|
||||||
name: 'CI/CD',
|
|
||||||
href: '/infrastructure/ci-cd',
|
|
||||||
description: 'Pipelines, Deployments & Container',
|
|
||||||
purpose: 'CI/CD Dashboard mit Gitea Actions Pipelines, Deployment-Status und Container-Management.',
|
|
||||||
audience: ['DevOps', 'Entwickler'],
|
|
||||||
subgroup: 'DevOps Pipeline',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tests',
|
|
||||||
name: 'Test Dashboard',
|
|
||||||
href: '/infrastructure/tests',
|
|
||||||
description: 'Test-Suites, Coverage & CI/CD',
|
|
||||||
purpose: 'Zentrales Dashboard fuer alle 280+ Tests. Unit (Go, Python), Integration, E2E (Playwright) und BQAS Quality Tests. Aggregiert Tests aus allen Services ohne physische Migration.',
|
|
||||||
audience: ['Entwickler', 'QA', 'DevOps'],
|
|
||||||
subgroup: 'DevOps Pipeline',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sbom',
|
|
||||||
name: 'SBOM',
|
|
||||||
href: '/infrastructure/sbom',
|
|
||||||
description: 'Software Bill of Materials',
|
|
||||||
purpose: 'Verwalten Sie alle Software-Abhaengigkeiten und deren Lizenzen.',
|
|
||||||
audience: ['DevOps', 'Compliance'],
|
|
||||||
oldAdminPath: '/admin/sbom',
|
|
||||||
subgroup: 'DevOps Pipeline',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'security',
|
|
||||||
name: 'Security',
|
|
||||||
href: '/infrastructure/security',
|
|
||||||
description: 'DevSecOps Dashboard & Scans',
|
|
||||||
purpose: 'Security-Scans, Vulnerability-Reports und OWASP-Compliance.',
|
|
||||||
audience: ['DevOps', 'Security'],
|
|
||||||
oldAdminPath: '/admin/security',
|
|
||||||
subgroup: 'DevOps Pipeline',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'middleware',
|
|
||||||
name: 'Middleware',
|
|
||||||
href: '/infrastructure/middleware',
|
|
||||||
description: 'Middleware Stack & API Gateway',
|
|
||||||
purpose: 'Ueberwachen und testen Sie den Middleware-Stack und API Gateway.',
|
|
||||||
audience: ['DevOps'],
|
|
||||||
oldAdminPath: '/admin/middleware',
|
|
||||||
subgroup: 'Infrastructure',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// =========================================================================
|
|
||||||
// Bildung & Schule
|
// Bildung & Schule
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
{
|
{
|
||||||
@@ -271,103 +210,6 @@ export const navigation: NavCategory[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Kommunikation & Alerts
|
|
||||||
// =========================================================================
|
|
||||||
{
|
|
||||||
id: 'communication',
|
|
||||||
name: 'Kommunikation & Alerts',
|
|
||||||
icon: 'mail',
|
|
||||||
color: '#22c55e', // Green
|
|
||||||
colorClass: 'communication',
|
|
||||||
description: 'Matrix, E-Mail & Benachrichtigungen',
|
|
||||||
modules: [
|
|
||||||
{
|
|
||||||
id: 'video-chat',
|
|
||||||
name: 'Video & Chat',
|
|
||||||
href: '/communication/video-chat',
|
|
||||||
description: 'Matrix & Jitsi Monitoring',
|
|
||||||
purpose: 'Dashboard fuer Matrix Synapse (E2EE Messaging) und Jitsi Meet (Videokonferenzen). Ueberwachen Sie Service-Status, aktive Meetings, Traffic und SysEleven Ressourcenplanung.',
|
|
||||||
audience: ['Admins', 'DevOps', 'Support'],
|
|
||||||
oldAdminPath: '/admin/communication',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'matrix',
|
|
||||||
name: 'Voice Service',
|
|
||||||
href: '/communication/matrix',
|
|
||||||
description: 'Voice-First Interface & Architektur',
|
|
||||||
purpose: 'Konfigurieren und testen Sie den Voice-Service (PersonaPlex-7B, TaskOrchestrator). Dokumentation der Voice-First Architektur mit DSGVO-Compliance.',
|
|
||||||
audience: ['Entwickler', 'Admins'],
|
|
||||||
oldAdminPath: '/admin/voice',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mail',
|
|
||||||
name: 'Unified Inbox',
|
|
||||||
href: '/communication/mail',
|
|
||||||
description: 'E-Mail-Konten & KI-Analyse',
|
|
||||||
purpose: 'Verwalten Sie E-Mail-Konten und nutzen Sie KI zur Kategorisierung.',
|
|
||||||
audience: ['Support', 'Admins'],
|
|
||||||
oldAdminPath: '/admin/mail',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'alerts',
|
|
||||||
name: 'Alerts Monitoring',
|
|
||||||
href: '/communication/alerts',
|
|
||||||
description: 'Google Alerts & Feed-Ueberwachung',
|
|
||||||
purpose: 'Ueberwachen Sie Google Alerts und RSS-Feeds fuer relevante Neuigkeiten.',
|
|
||||||
audience: ['Marketing', 'Admins'],
|
|
||||||
oldAdminPath: '/admin/alerts',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// =========================================================================
|
|
||||||
// Entwicklung & Produkte
|
|
||||||
// =========================================================================
|
|
||||||
{
|
|
||||||
id: 'development',
|
|
||||||
name: 'Entwicklung & Produkte',
|
|
||||||
icon: 'code',
|
|
||||||
color: '#64748b', // Slate
|
|
||||||
colorClass: 'development',
|
|
||||||
description: 'Workflow, Game, Docs & Brandbook',
|
|
||||||
modules: [
|
|
||||||
{
|
|
||||||
id: 'workflow',
|
|
||||||
name: 'Dev Workflow',
|
|
||||||
href: '/development/workflow',
|
|
||||||
description: 'Git, CI/CD & Team-Regeln',
|
|
||||||
purpose: 'Entwicklungs-Workflow mit Git, CI/CD Pipeline und Team-Konventionen. Pflichtlektuere fuer alle Entwickler.',
|
|
||||||
audience: ['Entwickler', 'DevOps'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'docs',
|
|
||||||
name: 'Developer Docs',
|
|
||||||
href: '/development/docs',
|
|
||||||
description: 'API & Architektur',
|
|
||||||
purpose: 'Durchsuchen Sie die API-Dokumentation und Architektur-Diagramme.',
|
|
||||||
audience: ['Entwickler'],
|
|
||||||
oldAdminPath: '/admin/docs',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'brandbook',
|
|
||||||
name: 'Brandbook',
|
|
||||||
href: '/development/brandbook',
|
|
||||||
description: 'Corporate Design',
|
|
||||||
purpose: 'Referenz fuer Logos, Farben, Typografie und Design-Richtlinien.',
|
|
||||||
audience: ['Designer', 'Marketing'],
|
|
||||||
oldAdminPath: '/admin/brandbook',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'screen-flow',
|
|
||||||
name: 'Screen Flow',
|
|
||||||
href: '/development/screen-flow',
|
|
||||||
description: 'UI Screen-Verbindungen',
|
|
||||||
purpose: 'Visualisieren Sie die Navigation und Screen-Verbindungen der App.',
|
|
||||||
audience: ['Designer', 'Entwickler'],
|
|
||||||
oldAdminPath: '/admin/screen-flow',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// =========================================================================
|
|
||||||
// Website
|
// Website
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const roles: Role[] = [
|
|||||||
name: 'Entwickler',
|
name: 'Entwickler',
|
||||||
description: 'Voller Zugriff auf alle Bereiche',
|
description: 'Voller Zugriff auf alle Bereiche',
|
||||||
icon: 'code',
|
icon: 'code',
|
||||||
visibleCategories: ['compliance-sdk', 'ai', 'infrastructure', 'education', 'communication', 'development', 'website'],
|
visibleCategories: ['compliance-sdk', 'ai', 'education', 'website'],
|
||||||
color: 'bg-primary-100 border-primary-300 text-primary-700',
|
color: 'bg-primary-100 border-primary-300 text-primary-700',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -31,7 +31,7 @@ export const roles: Role[] = [
|
|||||||
name: 'Manager',
|
name: 'Manager',
|
||||||
description: 'Executive Uebersicht',
|
description: 'Executive Uebersicht',
|
||||||
icon: 'chart',
|
icon: 'chart',
|
||||||
visibleCategories: ['compliance-sdk', 'communication', 'website'],
|
visibleCategories: ['compliance-sdk', 'website'],
|
||||||
color: 'bg-blue-100 border-blue-300 text-blue-700',
|
color: 'bg-blue-100 border-blue-300 text-blue-700',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
# Build stage
|
|
||||||
FROM golang:1.23-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install git for go mod download
|
|
||||||
RUN apk add --no-cache git
|
|
||||||
|
|
||||||
# Copy go mod files
|
|
||||||
COPY go.mod go.sum* ./
|
|
||||||
|
|
||||||
# Download dependencies
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o billing-service ./cmd/server
|
|
||||||
|
|
||||||
# Final stage
|
|
||||||
FROM alpine:3.19
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install ca-certificates for HTTPS requests (Stripe API)
|
|
||||||
RUN apk --no-cache add ca-certificates tzdata
|
|
||||||
|
|
||||||
# Copy binary from builder
|
|
||||||
COPY --from=builder /app/billing-service .
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8083
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8083/health || exit 1
|
|
||||||
|
|
||||||
# Run the application
|
|
||||||
CMD ["./billing-service"]
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
# Billing Service
|
|
||||||
|
|
||||||
Go-Microservice fuer Stripe-basiertes Subscription Management mit Task-basierter Abrechnung.
|
|
||||||
|
|
||||||
## Uebersicht
|
|
||||||
|
|
||||||
Der Billing Service verwaltet:
|
|
||||||
- Subscription Lifecycle (Trial, Active, Canceled)
|
|
||||||
- Task-basierte Kontingentierung (1 Task = 1 Einheit)
|
|
||||||
- Carryover-Logik (Tasks sammeln sich bis zu 5 Monate an)
|
|
||||||
- Stripe Integration (Checkout, Webhooks, Portal)
|
|
||||||
- Feature Gating und Entitlements
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Voraussetzungen
|
|
||||||
|
|
||||||
- Go 1.21+
|
|
||||||
- PostgreSQL 14+
|
|
||||||
- Docker (optional)
|
|
||||||
|
|
||||||
### Lokale Entwicklung
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Dependencies installieren
|
|
||||||
go mod download
|
|
||||||
|
|
||||||
# 2. Umgebungsvariablen setzen
|
|
||||||
export DATABASE_URL="postgres://user:pass@localhost:5432/breakpilot?sslmode=disable"
|
|
||||||
export JWT_SECRET="your-jwt-secret"
|
|
||||||
export STRIPE_SECRET_KEY="sk_test_..."
|
|
||||||
export STRIPE_WEBHOOK_SECRET="whsec_..."
|
|
||||||
export BILLING_SUCCESS_URL="http://localhost:3000/billing/success"
|
|
||||||
export BILLING_CANCEL_URL="http://localhost:3000/billing/cancel"
|
|
||||||
export INTERNAL_API_KEY="internal-api-key"
|
|
||||||
export TRIAL_PERIOD_DAYS="7"
|
|
||||||
export PORT="8083"
|
|
||||||
|
|
||||||
# 3. Service starten
|
|
||||||
go run cmd/server/main.go
|
|
||||||
|
|
||||||
# 4. Tests ausfuehren
|
|
||||||
go test -v ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mit Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Service bauen und starten
|
|
||||||
docker compose up billing-service
|
|
||||||
|
|
||||||
# Nur bauen
|
|
||||||
docker build -t billing-service .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architektur
|
|
||||||
|
|
||||||
```
|
|
||||||
billing-service/
|
|
||||||
├── cmd/server/main.go # Entry Point
|
|
||||||
├── internal/
|
|
||||||
│ ├── config/config.go # Konfiguration
|
|
||||||
│ ├── database/database.go # DB Connection + Migrations
|
|
||||||
│ ├── models/models.go # Datenmodelle
|
|
||||||
│ ├── middleware/middleware.go # JWT Auth, CORS, Rate Limiting
|
|
||||||
│ ├── services/
|
|
||||||
│ │ ├── subscription_service.go # Subscription Management
|
|
||||||
│ │ ├── task_service.go # Task Consumption
|
|
||||||
│ │ ├── entitlement_service.go # Feature Gating
|
|
||||||
│ │ ├── usage_service.go # Usage Tracking (Legacy)
|
|
||||||
│ │ └── stripe_service.go # Stripe API
|
|
||||||
│ └── handlers/
|
|
||||||
│ ├── billing_handlers.go # API Endpoints
|
|
||||||
│ └── webhook_handlers.go # Stripe Webhooks
|
|
||||||
├── Dockerfile
|
|
||||||
└── go.mod
|
|
||||||
```
|
|
||||||
|
|
||||||
## Task-basiertes Billing
|
|
||||||
|
|
||||||
### Konzept
|
|
||||||
|
|
||||||
- **1 Task = 1 Kontingentverbrauch** (unabhaengig von Seitenanzahl, Tokens, etc.)
|
|
||||||
- **Monatliches Kontingent**: Plan-abhaengig (Basic: 30, Standard: 100, Premium: Fair Use)
|
|
||||||
- **Carryover**: Ungenutzte Tasks sammeln sich bis zu 5 Monate an
|
|
||||||
- **Max Balance**: `monthly_allowance * 5` (z.B. Basic: max 150 Tasks)
|
|
||||||
|
|
||||||
### Task Types
|
|
||||||
|
|
||||||
```go
|
|
||||||
TaskTypeCorrection = "correction" // Korrekturaufgabe
|
|
||||||
TaskTypeLetter = "letter" // Brief erstellen
|
|
||||||
TaskTypeMeeting = "meeting" // Meeting-Protokoll
|
|
||||||
TaskTypeBatch = "batch" // Batch-Verarbeitung
|
|
||||||
TaskTypeOther = "other" // Sonstige
|
|
||||||
```
|
|
||||||
|
|
||||||
### Monatswechsel-Logik
|
|
||||||
|
|
||||||
Bei jedem API-Aufruf wird geprueft, ob ein Monat vergangen ist:
|
|
||||||
1. `last_renewal_at` pruefen
|
|
||||||
2. Falls >= 1 Monat: `task_balance += monthly_allowance`
|
|
||||||
3. Cap bei `max_task_balance`
|
|
||||||
4. `last_renewal_at` aktualisieren
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### User Endpoints (JWT Auth)
|
|
||||||
|
|
||||||
| Methode | Endpoint | Beschreibung |
|
|
||||||
|---------|----------|--------------|
|
|
||||||
| GET | `/api/v1/billing/status` | Aktueller Billing Status |
|
|
||||||
| GET | `/api/v1/billing/plans` | Verfuegbare Plaene |
|
|
||||||
| POST | `/api/v1/billing/trial/start` | Trial starten |
|
|
||||||
| POST | `/api/v1/billing/change-plan` | Plan wechseln |
|
|
||||||
| POST | `/api/v1/billing/cancel` | Abo kuendigen |
|
|
||||||
| GET | `/api/v1/billing/portal` | Stripe Portal URL |
|
|
||||||
|
|
||||||
### Internal Endpoints (API Key)
|
|
||||||
|
|
||||||
| Methode | Endpoint | Beschreibung |
|
|
||||||
|---------|----------|--------------|
|
|
||||||
| GET | `/api/v1/billing/entitlements/:userId` | Entitlements abrufen |
|
|
||||||
| GET | `/api/v1/billing/entitlements/check/:userId/:feature` | Feature pruefen |
|
|
||||||
| GET | `/api/v1/billing/tasks/check/:userId` | Task erlaubt? |
|
|
||||||
| POST | `/api/v1/billing/tasks/consume` | Task konsumieren |
|
|
||||||
| GET | `/api/v1/billing/tasks/usage/:userId` | Task Usage Info |
|
|
||||||
|
|
||||||
### Webhook
|
|
||||||
|
|
||||||
| Methode | Endpoint | Beschreibung |
|
|
||||||
|---------|----------|--------------|
|
|
||||||
| POST | `/api/v1/billing/webhook` | Stripe Webhooks |
|
|
||||||
|
|
||||||
## Plaene und Preise
|
|
||||||
|
|
||||||
| Plan | Preis | Tasks/Monat | Max Balance | Features |
|
|
||||||
|------|-------|-------------|-------------|----------|
|
|
||||||
| Basic | 9.90 EUR | 30 | 150 | Basis-Features |
|
|
||||||
| Standard | 19.90 EUR | 100 | 500 | + Templates, Batch |
|
|
||||||
| Premium | 39.90 EUR | Fair Use | 5000 | + Team, Admin, API |
|
|
||||||
|
|
||||||
### Fair Use Mode (Premium)
|
|
||||||
|
|
||||||
Im Premium-Plan:
|
|
||||||
- Keine praktische Begrenzung
|
|
||||||
- Tasks werden trotzdem getrackt (fuer Monitoring)
|
|
||||||
- Balance wird nicht dekrementiert
|
|
||||||
- `CheckTaskAllowed` gibt immer `true` zurueck
|
|
||||||
|
|
||||||
## Datenbank
|
|
||||||
|
|
||||||
### Wichtige Tabellen
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Task-basierte Nutzung pro Account
|
|
||||||
CREATE TABLE account_usage (
|
|
||||||
account_id UUID UNIQUE,
|
|
||||||
plan VARCHAR(50),
|
|
||||||
monthly_task_allowance INT,
|
|
||||||
max_task_balance INT,
|
|
||||||
task_balance INT,
|
|
||||||
last_renewal_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Einzelne Task-Records
|
|
||||||
CREATE TABLE tasks (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
account_id UUID,
|
|
||||||
task_type VARCHAR(50),
|
|
||||||
consumed BOOLEAN,
|
|
||||||
created_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Alle Tests
|
|
||||||
go test -v ./...
|
|
||||||
|
|
||||||
# Mit Coverage
|
|
||||||
go test -cover ./...
|
|
||||||
|
|
||||||
# Nur Models
|
|
||||||
go test -v ./internal/models/...
|
|
||||||
|
|
||||||
# Nur Services
|
|
||||||
go test -v ./internal/services/...
|
|
||||||
|
|
||||||
# Nur Handlers
|
|
||||||
go test -v ./internal/handlers/...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Stripe Integration
|
|
||||||
|
|
||||||
### Webhooks
|
|
||||||
|
|
||||||
Konfiguriere im Stripe Dashboard:
|
|
||||||
```
|
|
||||||
URL: https://your-domain.com/api/v1/billing/webhook
|
|
||||||
Events:
|
|
||||||
- checkout.session.completed
|
|
||||||
- customer.subscription.created
|
|
||||||
- customer.subscription.updated
|
|
||||||
- customer.subscription.deleted
|
|
||||||
- invoice.paid
|
|
||||||
- invoice.payment_failed
|
|
||||||
```
|
|
||||||
|
|
||||||
### Lokales Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Stripe CLI installieren
|
|
||||||
brew install stripe/stripe-cli/stripe
|
|
||||||
|
|
||||||
# Webhook forwarding
|
|
||||||
stripe listen --forward-to localhost:8083/api/v1/billing/webhook
|
|
||||||
|
|
||||||
# Test Events triggern
|
|
||||||
stripe trigger checkout.session.completed
|
|
||||||
stripe trigger invoice.paid
|
|
||||||
```
|
|
||||||
|
|
||||||
## Umgebungsvariablen
|
|
||||||
|
|
||||||
| Variable | Beschreibung | Beispiel |
|
|
||||||
|----------|--------------|----------|
|
|
||||||
| `DATABASE_URL` | PostgreSQL Connection String | `postgres://...` |
|
|
||||||
| `JWT_SECRET` | JWT Signing Secret | `your-secret` |
|
|
||||||
| `STRIPE_SECRET_KEY` | Stripe Secret Key | `sk_test_...` |
|
|
||||||
| `STRIPE_WEBHOOK_SECRET` | Webhook Signing Secret | `whsec_...` |
|
|
||||||
| `BILLING_SUCCESS_URL` | Checkout Success Redirect | `http://...` |
|
|
||||||
| `BILLING_CANCEL_URL` | Checkout Cancel Redirect | `http://...` |
|
|
||||||
| `INTERNAL_API_KEY` | Service-to-Service Auth | `internal-key` |
|
|
||||||
| `TRIAL_PERIOD_DAYS` | Trial Dauer in Tagen | `7` |
|
|
||||||
| `PORT` | Server Port | `8083` |
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
### Task Limit Reached
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "TASK_LIMIT_REACHED",
|
|
||||||
"message": "Dein Aufgaben-Kontingent ist aufgebraucht.",
|
|
||||||
"current_balance": 0,
|
|
||||||
"plan": "basic"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
HTTP Status: `402 Payment Required`
|
|
||||||
|
|
||||||
### No Subscription
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "NO_SUBSCRIPTION",
|
|
||||||
"message": "Kein aktives Abonnement gefunden."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
HTTP Status: `403 Forbidden`
|
|
||||||
|
|
||||||
## Frontend Integration
|
|
||||||
|
|
||||||
### Task Usage anzeigen
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Response von GET /api/v1/billing/status
|
|
||||||
interface TaskUsageInfo {
|
|
||||||
tasks_available: number; // z.B. 45
|
|
||||||
max_tasks: number; // z.B. 150
|
|
||||||
info_text: string; // "Aufgaben verfuegbar: 45 von max. 150"
|
|
||||||
tooltip_text: string; // "Aufgaben koennen sich bis zu 5 Monate ansammeln."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task konsumieren
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Vor jeder KI-Aktion
|
|
||||||
const response = await fetch('/api/v1/billing/tasks/check/' + userId);
|
|
||||||
const { allowed, message } = await response.json();
|
|
||||||
|
|
||||||
if (!allowed) {
|
|
||||||
showUpgradeDialog(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nach erfolgreicher KI-Aktion
|
|
||||||
await fetch('/api/v1/billing/tasks/consume', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ user_id: userId, task_type: 'correction' })
|
|
||||||
});
|
|
||||||
```
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/breakpilot/billing-service/internal/config"
|
|
||||||
"github.com/breakpilot/billing-service/internal/database"
|
|
||||||
"github.com/breakpilot/billing-service/internal/handlers"
|
|
||||||
"github.com/breakpilot/billing-service/internal/middleware"
|
|
||||||
"github.com/breakpilot/billing-service/internal/services"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// Load configuration
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize database
|
|
||||||
db, err := database.Connect(cfg.DatabaseURL)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to connect to database: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
// Run migrations
|
|
||||||
if err := database.Migrate(db); err != nil {
|
|
||||||
log.Fatalf("Failed to run migrations: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup Gin router
|
|
||||||
if cfg.Environment == "production" {
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
router := gin.Default()
|
|
||||||
|
|
||||||
// Global middleware
|
|
||||||
router.Use(middleware.CORS())
|
|
||||||
router.Use(middleware.RequestLogger())
|
|
||||||
router.Use(middleware.RateLimiter())
|
|
||||||
|
|
||||||
// Health check (no auth required)
|
|
||||||
router.GET("/health", func(c *gin.Context) {
|
|
||||||
c.JSON(200, gin.H{
|
|
||||||
"status": "healthy",
|
|
||||||
"service": "billing-service",
|
|
||||||
"version": "1.0.0",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Initialize services
|
|
||||||
subscriptionService := services.NewSubscriptionService(db)
|
|
||||||
|
|
||||||
// Create Stripe service (mock or real depending on config)
|
|
||||||
var stripeService *services.StripeService
|
|
||||||
if cfg.IsMockMode() {
|
|
||||||
log.Println("Starting in MOCK MODE - Stripe API calls will be simulated")
|
|
||||||
stripeService = services.NewMockStripeService(
|
|
||||||
cfg.BillingSuccessURL,
|
|
||||||
cfg.BillingCancelURL,
|
|
||||||
cfg.TrialPeriodDays,
|
|
||||||
subscriptionService,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
stripeService = services.NewStripeService(
|
|
||||||
cfg.StripeSecretKey,
|
|
||||||
cfg.StripeWebhookSecret,
|
|
||||||
cfg.BillingSuccessURL,
|
|
||||||
cfg.BillingCancelURL,
|
|
||||||
cfg.TrialPeriodDays,
|
|
||||||
subscriptionService,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
entitlementService := services.NewEntitlementService(db, subscriptionService)
|
|
||||||
usageService := services.NewUsageService(db, entitlementService)
|
|
||||||
|
|
||||||
// Initialize handlers
|
|
||||||
billingHandler := handlers.NewBillingHandler(
|
|
||||||
db,
|
|
||||||
subscriptionService,
|
|
||||||
stripeService,
|
|
||||||
entitlementService,
|
|
||||||
usageService,
|
|
||||||
)
|
|
||||||
webhookHandler := handlers.NewWebhookHandler(
|
|
||||||
db,
|
|
||||||
cfg.StripeWebhookSecret,
|
|
||||||
subscriptionService,
|
|
||||||
entitlementService,
|
|
||||||
)
|
|
||||||
|
|
||||||
// API v1 routes
|
|
||||||
v1 := router.Group("/api/v1/billing")
|
|
||||||
{
|
|
||||||
// Stripe webhook (no auth - uses Stripe signature)
|
|
||||||
v1.POST("/webhook", webhookHandler.HandleStripeWebhook)
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// User Endpoints (require JWT auth)
|
|
||||||
// =============================================
|
|
||||||
user := v1.Group("")
|
|
||||||
user.Use(middleware.AuthMiddleware(cfg.JWTSecret))
|
|
||||||
{
|
|
||||||
// Subscription status and management
|
|
||||||
user.GET("/status", billingHandler.GetBillingStatus)
|
|
||||||
user.GET("/plans", billingHandler.GetPlans)
|
|
||||||
user.POST("/trial/start", billingHandler.StartTrial)
|
|
||||||
user.POST("/change-plan", billingHandler.ChangePlan)
|
|
||||||
user.POST("/cancel", billingHandler.CancelSubscription)
|
|
||||||
user.GET("/portal", billingHandler.GetCustomerPortal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Internal Endpoints (service-to-service)
|
|
||||||
// =============================================
|
|
||||||
internal := v1.Group("")
|
|
||||||
internal.Use(middleware.InternalAPIKeyMiddleware(cfg.InternalAPIKey))
|
|
||||||
{
|
|
||||||
// Entitlements
|
|
||||||
internal.GET("/entitlements/:userId", billingHandler.GetEntitlements)
|
|
||||||
internal.GET("/entitlements/check/:userId/:feature", billingHandler.CheckEntitlement)
|
|
||||||
|
|
||||||
// Usage tracking
|
|
||||||
internal.POST("/usage/track", billingHandler.TrackUsage)
|
|
||||||
internal.GET("/usage/check/:userId/:type", billingHandler.CheckUsage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start server
|
|
||||||
port := cfg.Port
|
|
||||||
if port == "" {
|
|
||||||
port = "8083"
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Starting Billing Service on port %s", port)
|
|
||||||
if err := router.Run(":" + port); err != nil {
|
|
||||||
log.Fatalf("Failed to start server: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
module github.com/breakpilot/billing-service
|
|
||||||
|
|
||||||
go 1.23.0
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/gin-gonic/gin v1.11.0
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
|
||||||
github.com/google/uuid v1.6.0
|
|
||||||
github.com/jackc/pgx/v5 v5.7.6
|
|
||||||
github.com/joho/godotenv v1.5.1
|
|
||||||
github.com/stripe/stripe-go/v76 v76.25.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
|
||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
|
||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
|
||||||
golang.org/x/crypto v0.40.0 // indirect
|
|
||||||
golang.org/x/mod v0.25.0 // indirect
|
|
||||||
golang.org/x/net v0.42.0 // indirect
|
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
|
||||||
golang.org/x/text v0.27.0 // indirect
|
|
||||||
golang.org/x/tools v0.34.0 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
|
||||||
)
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
|
||||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
|
||||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
|
||||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
|
||||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
|
||||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
|
||||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
|
||||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
||||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
|
||||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
|
||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
|
||||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
|
||||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
|
||||||
github.com/stripe/stripe-go/v76 v76.25.0 h1:kmDoOTvdQSTQssQzWZQQkgbAR2Q8eXdMWbN/ylNalWA=
|
|
||||||
github.com/stripe/stripe-go/v76 v76.25.0/go.mod h1:rw1MxjlAKKcZ+3FOXgTHgwiOa2ya6CPq6ykpJ0Q6Po4=
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
|
||||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
|
||||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
|
||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
|
||||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
|
||||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
|
||||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
|
||||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
|
||||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
|
||||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
|
||||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
|
||||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
|
||||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
|
||||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
|
||||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config holds all configuration for the billing service
|
|
||||||
type Config struct {
|
|
||||||
// Server
|
|
||||||
Port string
|
|
||||||
Environment string
|
|
||||||
|
|
||||||
// Database
|
|
||||||
DatabaseURL string
|
|
||||||
|
|
||||||
// JWT (shared with consent-service)
|
|
||||||
JWTSecret string
|
|
||||||
|
|
||||||
// Stripe
|
|
||||||
StripeSecretKey string
|
|
||||||
StripeWebhookSecret string
|
|
||||||
StripePublishableKey string
|
|
||||||
StripeMockMode bool // If true, Stripe calls are mocked (for dev without Stripe keys)
|
|
||||||
|
|
||||||
// URLs
|
|
||||||
BillingSuccessURL string
|
|
||||||
BillingCancelURL string
|
|
||||||
FrontendURL string
|
|
||||||
|
|
||||||
// Trial
|
|
||||||
TrialPeriodDays int
|
|
||||||
|
|
||||||
// CORS
|
|
||||||
AllowedOrigins []string
|
|
||||||
|
|
||||||
// Rate Limiting
|
|
||||||
RateLimitRequests int
|
|
||||||
RateLimitWindow int // in seconds
|
|
||||||
|
|
||||||
// Internal API Key (for service-to-service communication)
|
|
||||||
InternalAPIKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load loads configuration from environment variables
|
|
||||||
func Load() (*Config, error) {
|
|
||||||
// Load .env file if exists (for development)
|
|
||||||
_ = godotenv.Load()
|
|
||||||
|
|
||||||
cfg := &Config{
|
|
||||||
Port: getEnv("PORT", "8083"),
|
|
||||||
Environment: getEnv("ENVIRONMENT", "development"),
|
|
||||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
|
||||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
|
||||||
|
|
||||||
// Stripe
|
|
||||||
StripeSecretKey: getEnv("STRIPE_SECRET_KEY", ""),
|
|
||||||
StripeWebhookSecret: getEnv("STRIPE_WEBHOOK_SECRET", ""),
|
|
||||||
StripePublishableKey: getEnv("STRIPE_PUBLISHABLE_KEY", ""),
|
|
||||||
StripeMockMode: getEnvBool("STRIPE_MOCK_MODE", false),
|
|
||||||
|
|
||||||
// URLs
|
|
||||||
BillingSuccessURL: getEnv("BILLING_SUCCESS_URL", "http://localhost:8000/app/billing/success"),
|
|
||||||
BillingCancelURL: getEnv("BILLING_CANCEL_URL", "http://localhost:8000/app/billing/cancel"),
|
|
||||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:8000"),
|
|
||||||
|
|
||||||
// Trial
|
|
||||||
TrialPeriodDays: getEnvInt("TRIAL_PERIOD_DAYS", 7),
|
|
||||||
|
|
||||||
// Rate Limiting
|
|
||||||
RateLimitRequests: getEnvInt("RATE_LIMIT_REQUESTS", 100),
|
|
||||||
RateLimitWindow: getEnvInt("RATE_LIMIT_WINDOW", 60),
|
|
||||||
|
|
||||||
// Internal API
|
|
||||||
InternalAPIKey: getEnv("INTERNAL_API_KEY", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse allowed origins
|
|
||||||
originsStr := getEnv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:8000")
|
|
||||||
cfg.AllowedOrigins = parseCommaSeparated(originsStr)
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if cfg.DatabaseURL == "" {
|
|
||||||
return nil, fmt.Errorf("DATABASE_URL is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.JWTSecret == "" {
|
|
||||||
return nil, fmt.Errorf("JWT_SECRET is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stripe key is required unless mock mode is enabled
|
|
||||||
if cfg.StripeSecretKey == "" && !cfg.StripeMockMode {
|
|
||||||
// In development mode, auto-enable mock mode if no Stripe key
|
|
||||||
if cfg.Environment == "development" {
|
|
||||||
cfg.StripeMockMode = true
|
|
||||||
} else {
|
|
||||||
return nil, fmt.Errorf("STRIPE_SECRET_KEY is required (set STRIPE_MOCK_MODE=true to bypass in dev)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsMockMode returns true if Stripe should be mocked
|
|
||||||
func (c *Config) IsMockMode() bool {
|
|
||||||
return c.StripeMockMode
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnv(key, defaultValue string) string {
|
|
||||||
if value := os.Getenv(key); value != "" {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnvInt(key string, defaultValue int) int {
|
|
||||||
if value := os.Getenv(key); value != "" {
|
|
||||||
var result int
|
|
||||||
fmt.Sscanf(value, "%d", &result)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnvBool(key string, defaultValue bool) bool {
|
|
||||||
if value := os.Getenv(key); value != "" {
|
|
||||||
return value == "true" || value == "1" || value == "yes"
|
|
||||||
}
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCommaSeparated(s string) []string {
|
|
||||||
if s == "" {
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
var result []string
|
|
||||||
start := 0
|
|
||||||
for i := 0; i <= len(s); i++ {
|
|
||||||
if i == len(s) || s[i] == ',' {
|
|
||||||
item := s[start:i]
|
|
||||||
// Trim whitespace
|
|
||||||
for len(item) > 0 && item[0] == ' ' {
|
|
||||||
item = item[1:]
|
|
||||||
}
|
|
||||||
for len(item) > 0 && item[len(item)-1] == ' ' {
|
|
||||||
item = item[:len(item)-1]
|
|
||||||
}
|
|
||||||
if item != "" {
|
|
||||||
result = append(result, item)
|
|
||||||
}
|
|
||||||
start = i + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DB wraps the pgx pool
|
|
||||||
type DB struct {
|
|
||||||
Pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect establishes a connection to the PostgreSQL database
|
|
||||||
func Connect(databaseURL string) (*DB, error) {
|
|
||||||
config, err := pgxpool.ParseConfig(databaseURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse database URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure connection pool
|
|
||||||
config.MaxConns = 15
|
|
||||||
config.MinConns = 3
|
|
||||||
config.MaxConnLifetime = time.Hour
|
|
||||||
config.MaxConnIdleTime = 30 * time.Minute
|
|
||||||
config.HealthCheckPeriod = time.Minute
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create connection pool: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test the connection
|
|
||||||
if err := pool.Ping(ctx); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &DB{Pool: pool}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close closes the database connection pool
|
|
||||||
func (db *DB) Close() {
|
|
||||||
db.Pool.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate runs database migrations for the billing service
|
|
||||||
func Migrate(db *DB) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
migrations := []string{
|
|
||||||
// =============================================
|
|
||||||
// Billing Service Tables
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// Subscriptions - core subscription data
|
|
||||||
`CREATE TABLE IF NOT EXISTS subscriptions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL,
|
|
||||||
stripe_customer_id VARCHAR(255),
|
|
||||||
stripe_subscription_id VARCHAR(255) UNIQUE,
|
|
||||||
plan_id VARCHAR(50) NOT NULL,
|
|
||||||
status VARCHAR(30) NOT NULL DEFAULT 'trialing',
|
|
||||||
trial_end TIMESTAMPTZ,
|
|
||||||
current_period_start TIMESTAMPTZ,
|
|
||||||
current_period_end TIMESTAMPTZ,
|
|
||||||
cancel_at_period_end BOOLEAN DEFAULT FALSE,
|
|
||||||
canceled_at TIMESTAMPTZ,
|
|
||||||
ended_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(user_id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Billing Plans - cached from Stripe
|
|
||||||
`CREATE TABLE IF NOT EXISTS billing_plans (
|
|
||||||
id VARCHAR(50) PRIMARY KEY,
|
|
||||||
stripe_price_id VARCHAR(255) UNIQUE,
|
|
||||||
stripe_product_id VARCHAR(255),
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
price_cents INT NOT NULL,
|
|
||||||
currency VARCHAR(3) DEFAULT 'eur',
|
|
||||||
interval VARCHAR(10) DEFAULT 'month',
|
|
||||||
features JSONB DEFAULT '{}',
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
sort_order INT DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Usage Summary - aggregated usage per period
|
|
||||||
`CREATE TABLE IF NOT EXISTS usage_summary (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL,
|
|
||||||
usage_type VARCHAR(50) NOT NULL,
|
|
||||||
period_start TIMESTAMPTZ NOT NULL,
|
|
||||||
total_count INT DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(user_id, usage_type, period_start)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// User Entitlements - cached entitlements for fast lookups
|
|
||||||
`CREATE TABLE IF NOT EXISTS user_entitlements (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL UNIQUE,
|
|
||||||
plan_id VARCHAR(50) NOT NULL,
|
|
||||||
ai_requests_limit INT DEFAULT 0,
|
|
||||||
ai_requests_used INT DEFAULT 0,
|
|
||||||
documents_limit INT DEFAULT 0,
|
|
||||||
documents_used INT DEFAULT 0,
|
|
||||||
features JSONB DEFAULT '{}',
|
|
||||||
period_start TIMESTAMPTZ,
|
|
||||||
period_end TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Stripe Webhook Events - for idempotency
|
|
||||||
`CREATE TABLE IF NOT EXISTS stripe_webhook_events (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
stripe_event_id VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
event_type VARCHAR(100) NOT NULL,
|
|
||||||
processed BOOLEAN DEFAULT FALSE,
|
|
||||||
processed_at TIMESTAMPTZ,
|
|
||||||
payload JSONB,
|
|
||||||
error_message TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Billing Audit Log
|
|
||||||
`CREATE TABLE IF NOT EXISTS billing_audit_log (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID,
|
|
||||||
action VARCHAR(50) NOT NULL,
|
|
||||||
entity_type VARCHAR(50),
|
|
||||||
entity_id VARCHAR(255),
|
|
||||||
old_value JSONB,
|
|
||||||
new_value JSONB,
|
|
||||||
metadata JSONB,
|
|
||||||
ip_address INET,
|
|
||||||
user_agent TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Invoices - cached from Stripe
|
|
||||||
`CREATE TABLE IF NOT EXISTS invoices (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL,
|
|
||||||
stripe_invoice_id VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
stripe_subscription_id VARCHAR(255),
|
|
||||||
status VARCHAR(30) NOT NULL,
|
|
||||||
amount_due INT NOT NULL,
|
|
||||||
amount_paid INT DEFAULT 0,
|
|
||||||
currency VARCHAR(3) DEFAULT 'eur',
|
|
||||||
hosted_invoice_url TEXT,
|
|
||||||
invoice_pdf TEXT,
|
|
||||||
period_start TIMESTAMPTZ,
|
|
||||||
period_end TIMESTAMPTZ,
|
|
||||||
due_date TIMESTAMPTZ,
|
|
||||||
paid_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Task-based Billing Tables
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// Account Usage - tracks task balance per account
|
|
||||||
`CREATE TABLE IF NOT EXISTS account_usage (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
account_id UUID NOT NULL UNIQUE,
|
|
||||||
plan VARCHAR(50) NOT NULL,
|
|
||||||
monthly_task_allowance INT NOT NULL,
|
|
||||||
carryover_months_cap INT DEFAULT 5,
|
|
||||||
max_task_balance INT NOT NULL,
|
|
||||||
task_balance INT NOT NULL,
|
|
||||||
last_renewal_at TIMESTAMPTZ NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Tasks - individual task consumption records
|
|
||||||
`CREATE TABLE IF NOT EXISTS tasks (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
account_id UUID NOT NULL,
|
|
||||||
task_type VARCHAR(50) NOT NULL,
|
|
||||||
consumed BOOLEAN DEFAULT TRUE,
|
|
||||||
page_count INT DEFAULT 0,
|
|
||||||
token_count INT DEFAULT 0,
|
|
||||||
process_time INT DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Indexes
|
|
||||||
// =============================================
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_customer ON subscriptions(stripe_customer_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_sub ON subscriptions(stripe_subscription_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_subscriptions_trial_end ON subscriptions(trial_end)`,
|
|
||||||
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_usage_summary_user ON usage_summary(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_usage_summary_period ON usage_summary(period_start)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_usage_summary_type ON usage_summary(usage_type)`,
|
|
||||||
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_user_entitlements_user ON user_entitlements(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_user_entitlements_plan ON user_entitlements(plan_id)`,
|
|
||||||
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_stripe_webhook_events_event_id ON stripe_webhook_events(stripe_event_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_stripe_webhook_events_type ON stripe_webhook_events(event_type)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_stripe_webhook_events_processed ON stripe_webhook_events(processed)`,
|
|
||||||
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_billing_audit_log_user ON billing_audit_log(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_billing_audit_log_action ON billing_audit_log(action)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_billing_audit_log_created ON billing_audit_log(created_at)`,
|
|
||||||
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_invoices_user ON invoices(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_invoices_stripe_invoice ON invoices(stripe_invoice_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status)`,
|
|
||||||
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_account_usage_account ON account_usage(account_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_account_usage_plan ON account_usage(plan)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_account_usage_renewal ON account_usage(last_renewal_at)`,
|
|
||||||
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_tasks_account ON tasks(account_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_tasks_type ON tasks(task_type)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_tasks_created ON tasks(created_at)`,
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Insert default plans
|
|
||||||
// =============================================
|
|
||||||
`INSERT INTO billing_plans (id, name, description, price_cents, currency, interval, features, sort_order)
|
|
||||||
VALUES
|
|
||||||
('basic', 'Basic', 'Perfekt für den Einstieg', 990, 'eur', 'month',
|
|
||||||
'{"ai_requests_limit": 300, "documents_limit": 50, "feature_flags": ["basic_ai", "basic_documents"], "max_team_members": 1, "priority_support": false, "custom_branding": false}',
|
|
||||||
1),
|
|
||||||
('standard', 'Standard', 'Für regelmäßige Nutzer', 1990, 'eur', 'month',
|
|
||||||
'{"ai_requests_limit": 1500, "documents_limit": 200, "feature_flags": ["basic_ai", "basic_documents", "templates", "batch_processing"], "max_team_members": 3, "priority_support": false, "custom_branding": false}',
|
|
||||||
2),
|
|
||||||
('premium', 'Premium', 'Für Teams und Power-User', 3990, 'eur', 'month',
|
|
||||||
'{"ai_requests_limit": 5000, "documents_limit": 1000, "feature_flags": ["basic_ai", "basic_documents", "templates", "batch_processing", "team_features", "admin_panel", "audit_log", "api_access"], "max_team_members": 10, "priority_support": true, "custom_branding": true}',
|
|
||||||
3)
|
|
||||||
ON CONFLICT (id) DO NOTHING`,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, migration := range migrations {
|
|
||||||
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
|
||||||
return fmt.Errorf("failed to run migration: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/breakpilot/billing-service/internal/database"
|
|
||||||
"github.com/breakpilot/billing-service/internal/middleware"
|
|
||||||
"github.com/breakpilot/billing-service/internal/models"
|
|
||||||
"github.com/breakpilot/billing-service/internal/services"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BillingHandler handles billing-related HTTP requests
|
|
||||||
type BillingHandler struct {
|
|
||||||
db *database.DB
|
|
||||||
subscriptionService *services.SubscriptionService
|
|
||||||
stripeService *services.StripeService
|
|
||||||
entitlementService *services.EntitlementService
|
|
||||||
usageService *services.UsageService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewBillingHandler creates a new BillingHandler
|
|
||||||
func NewBillingHandler(
|
|
||||||
db *database.DB,
|
|
||||||
subscriptionService *services.SubscriptionService,
|
|
||||||
stripeService *services.StripeService,
|
|
||||||
entitlementService *services.EntitlementService,
|
|
||||||
usageService *services.UsageService,
|
|
||||||
) *BillingHandler {
|
|
||||||
return &BillingHandler{
|
|
||||||
db: db,
|
|
||||||
subscriptionService: subscriptionService,
|
|
||||||
stripeService: stripeService,
|
|
||||||
entitlementService: entitlementService,
|
|
||||||
usageService: usageService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBillingStatus returns the current billing status for a user
|
|
||||||
// GET /api/v1/billing/status
|
|
||||||
func (h *BillingHandler) GetBillingStatus(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID.String() == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
|
||||||
"error": "unauthorized",
|
|
||||||
"message": "User not authenticated",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
// Get subscription
|
|
||||||
subscription, err := h.subscriptionService.GetByUserID(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "internal_error",
|
|
||||||
"message": "Failed to get subscription",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get available plans
|
|
||||||
plans, err := h.subscriptionService.GetAvailablePlans(ctx)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "internal_error",
|
|
||||||
"message": "Failed to get plans",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
response := models.BillingStatusResponse{
|
|
||||||
HasSubscription: subscription != nil,
|
|
||||||
AvailablePlans: plans,
|
|
||||||
}
|
|
||||||
|
|
||||||
if subscription != nil {
|
|
||||||
// Get plan details
|
|
||||||
plan, _ := h.subscriptionService.GetPlanByID(ctx, string(subscription.PlanID))
|
|
||||||
|
|
||||||
subInfo := &models.SubscriptionInfo{
|
|
||||||
PlanID: subscription.PlanID,
|
|
||||||
Status: subscription.Status,
|
|
||||||
IsTrialing: subscription.Status == models.StatusTrialing,
|
|
||||||
CancelAtPeriodEnd: subscription.CancelAtPeriodEnd,
|
|
||||||
CurrentPeriodEnd: subscription.CurrentPeriodEnd,
|
|
||||||
}
|
|
||||||
|
|
||||||
if plan != nil {
|
|
||||||
subInfo.PlanName = plan.Name
|
|
||||||
subInfo.PriceCents = plan.PriceCents
|
|
||||||
subInfo.Currency = plan.Currency
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate trial days left
|
|
||||||
if subscription.TrialEnd != nil && subscription.Status == models.StatusTrialing {
|
|
||||||
// TODO: Calculate days left
|
|
||||||
}
|
|
||||||
|
|
||||||
response.Subscription = subInfo
|
|
||||||
|
|
||||||
// Get task usage info (legacy usage tracking - see TaskService for new task-based usage)
|
|
||||||
// TODO: Replace with TaskService.GetTaskUsageInfo for task-based billing
|
|
||||||
_, _ = h.usageService.GetUsageSummary(ctx, userID)
|
|
||||||
|
|
||||||
// Get entitlements
|
|
||||||
entitlements, _ := h.entitlementService.GetEntitlements(ctx, userID)
|
|
||||||
if entitlements != nil {
|
|
||||||
response.Entitlements = entitlements
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPlans returns all available billing plans
|
|
||||||
// GET /api/v1/billing/plans
|
|
||||||
func (h *BillingHandler) GetPlans(c *gin.Context) {
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
plans, err := h.subscriptionService.GetAvailablePlans(ctx)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "internal_error",
|
|
||||||
"message": "Failed to get plans",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"plans": plans,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartTrial starts a trial for the user with a specific plan
|
|
||||||
// POST /api/v1/billing/trial/start
|
|
||||||
func (h *BillingHandler) StartTrial(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID.String() == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
|
||||||
"error": "unauthorized",
|
|
||||||
"message": "User not authenticated",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.StartTrialRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": "invalid_request",
|
|
||||||
"message": "Invalid request body",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
// Check if user already has a subscription
|
|
||||||
existing, _ := h.subscriptionService.GetByUserID(ctx, userID)
|
|
||||||
if existing != nil {
|
|
||||||
c.JSON(http.StatusConflict, gin.H{
|
|
||||||
"error": "subscription_exists",
|
|
||||||
"message": "User already has a subscription",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user email from context
|
|
||||||
email, _ := c.Get("email")
|
|
||||||
emailStr, _ := email.(string)
|
|
||||||
|
|
||||||
// Create Stripe checkout session
|
|
||||||
checkoutURL, sessionID, err := h.stripeService.CreateCheckoutSession(ctx, userID, emailStr, req.PlanID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "stripe_error",
|
|
||||||
"message": "Failed to create checkout session",
|
|
||||||
"details": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.StartTrialResponse{
|
|
||||||
CheckoutURL: checkoutURL,
|
|
||||||
SessionID: sessionID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChangePlan changes the user's subscription plan
|
|
||||||
// POST /api/v1/billing/change-plan
|
|
||||||
func (h *BillingHandler) ChangePlan(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID.String() == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
|
||||||
"error": "unauthorized",
|
|
||||||
"message": "User not authenticated",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.ChangePlanRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": "invalid_request",
|
|
||||||
"message": "Invalid request body",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
// Get current subscription
|
|
||||||
subscription, err := h.subscriptionService.GetByUserID(ctx, userID)
|
|
||||||
if err != nil || subscription == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{
|
|
||||||
"error": "no_subscription",
|
|
||||||
"message": "No active subscription found",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Change plan via Stripe
|
|
||||||
err = h.stripeService.ChangePlan(ctx, subscription.StripeSubscriptionID, req.NewPlanID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "stripe_error",
|
|
||||||
"message": "Failed to change plan",
|
|
||||||
"details": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.ChangePlanResponse{
|
|
||||||
Success: true,
|
|
||||||
Message: "Plan changed successfully",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CancelSubscription cancels the user's subscription at period end
|
|
||||||
// POST /api/v1/billing/cancel
|
|
||||||
func (h *BillingHandler) CancelSubscription(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID.String() == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
|
||||||
"error": "unauthorized",
|
|
||||||
"message": "User not authenticated",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
// Get current subscription
|
|
||||||
subscription, err := h.subscriptionService.GetByUserID(ctx, userID)
|
|
||||||
if err != nil || subscription == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{
|
|
||||||
"error": "no_subscription",
|
|
||||||
"message": "No active subscription found",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel at period end via Stripe
|
|
||||||
err = h.stripeService.CancelSubscription(ctx, subscription.StripeSubscriptionID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "stripe_error",
|
|
||||||
"message": "Failed to cancel subscription",
|
|
||||||
"details": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.CancelSubscriptionResponse{
|
|
||||||
Success: true,
|
|
||||||
Message: "Subscription will be canceled at the end of the billing period",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCustomerPortal returns a URL to the Stripe customer portal
|
|
||||||
// GET /api/v1/billing/portal
|
|
||||||
func (h *BillingHandler) GetCustomerPortal(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID.String() == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
|
||||||
"error": "unauthorized",
|
|
||||||
"message": "User not authenticated",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
// Get current subscription
|
|
||||||
subscription, err := h.subscriptionService.GetByUserID(ctx, userID)
|
|
||||||
if err != nil || subscription == nil || subscription.StripeCustomerID == "" {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{
|
|
||||||
"error": "no_subscription",
|
|
||||||
"message": "No active subscription found",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create portal session
|
|
||||||
portalURL, err := h.stripeService.CreateCustomerPortalSession(ctx, subscription.StripeCustomerID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "stripe_error",
|
|
||||||
"message": "Failed to create portal session",
|
|
||||||
"details": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.CustomerPortalResponse{
|
|
||||||
PortalURL: portalURL,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Internal Endpoints (Service-to-Service)
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// GetEntitlements returns entitlements for a user (internal)
|
|
||||||
// GET /api/v1/billing/entitlements/:userId
|
|
||||||
func (h *BillingHandler) GetEntitlements(c *gin.Context) {
|
|
||||||
userIDStr := c.Param("userId")
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
entitlements, err := h.entitlementService.GetEntitlementsByUserIDString(ctx, userIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "internal_error",
|
|
||||||
"message": "Failed to get entitlements",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if entitlements == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{
|
|
||||||
"error": "not_found",
|
|
||||||
"message": "No entitlements found for user",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, entitlements)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TrackUsage tracks usage for a user (internal)
|
|
||||||
// POST /api/v1/billing/usage/track
|
|
||||||
func (h *BillingHandler) TrackUsage(c *gin.Context) {
|
|
||||||
var req models.TrackUsageRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": "invalid_request",
|
|
||||||
"message": "Invalid request body",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
quantity := req.Quantity
|
|
||||||
if quantity <= 0 {
|
|
||||||
quantity = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
err := h.usageService.TrackUsage(ctx, req.UserID, req.UsageType, quantity)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "internal_error",
|
|
||||||
"message": "Failed to track usage",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": true,
|
|
||||||
"message": "Usage tracked",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckUsage checks if usage is allowed (internal)
|
|
||||||
// GET /api/v1/billing/usage/check/:userId/:type
|
|
||||||
func (h *BillingHandler) CheckUsage(c *gin.Context) {
|
|
||||||
userIDStr := c.Param("userId")
|
|
||||||
usageType := c.Param("type")
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
response, err := h.usageService.CheckUsageAllowed(ctx, userIDStr, usageType)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "internal_error",
|
|
||||||
"message": "Failed to check usage",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckEntitlement checks if a user has a specific entitlement (internal)
|
|
||||||
// GET /api/v1/billing/entitlements/check/:userId/:feature
|
|
||||||
func (h *BillingHandler) CheckEntitlement(c *gin.Context) {
|
|
||||||
userIDStr := c.Param("userId")
|
|
||||||
feature := c.Param("feature")
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
hasEntitlement, planID, err := h.entitlementService.CheckEntitlement(ctx, userIDStr, feature)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "internal_error",
|
|
||||||
"message": "Failed to check entitlement",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.EntitlementCheckResponse{
|
|
||||||
HasEntitlement: hasEntitlement,
|
|
||||||
PlanID: planID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,612 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/breakpilot/billing-service/internal/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
// Set Gin to test mode
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetPlans_ResponseFormat(t *testing.T) {
|
|
||||||
// Test that GetPlans returns the expected response structure
|
|
||||||
// Since we don't have a real database connection in unit tests,
|
|
||||||
// we test the expected structure and format
|
|
||||||
|
|
||||||
// Test that default plans are well-formed
|
|
||||||
plans := models.GetDefaultPlans()
|
|
||||||
|
|
||||||
if len(plans) == 0 {
|
|
||||||
t.Error("Default plans should not be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, plan := range plans {
|
|
||||||
// Verify JSON serialization works
|
|
||||||
data, err := json.Marshal(plan)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Failed to marshal plan %s: %v", plan.ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify we can unmarshal back
|
|
||||||
var decoded models.BillingPlan
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Failed to unmarshal plan %s: %v", plan.ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify key fields
|
|
||||||
if decoded.ID != plan.ID {
|
|
||||||
t.Errorf("Plan ID mismatch: got %s, expected %s", decoded.ID, plan.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBillingStatusResponse_Structure(t *testing.T) {
|
|
||||||
// Test the response structure
|
|
||||||
response := models.BillingStatusResponse{
|
|
||||||
HasSubscription: true,
|
|
||||||
Subscription: &models.SubscriptionInfo{
|
|
||||||
PlanID: models.PlanStandard,
|
|
||||||
PlanName: "Standard",
|
|
||||||
Status: models.StatusActive,
|
|
||||||
IsTrialing: false,
|
|
||||||
CancelAtPeriodEnd: false,
|
|
||||||
PriceCents: 1990,
|
|
||||||
Currency: "eur",
|
|
||||||
},
|
|
||||||
TaskUsage: &models.TaskUsageInfo{
|
|
||||||
TasksAvailable: 85,
|
|
||||||
MaxTasks: 500,
|
|
||||||
InfoText: "Aufgaben verfuegbar: 85 von max. 500",
|
|
||||||
TooltipText: "Aufgaben koennen sich bis zu 5 Monate ansammeln.",
|
|
||||||
},
|
|
||||||
Entitlements: &models.EntitlementInfo{
|
|
||||||
Features: []string{"basic_ai", "basic_documents", "templates", "batch_processing"},
|
|
||||||
MaxTeamMembers: 3,
|
|
||||||
PrioritySupport: false,
|
|
||||||
CustomBranding: false,
|
|
||||||
BatchProcessing: true,
|
|
||||||
CustomTemplates: true,
|
|
||||||
FairUseMode: false,
|
|
||||||
},
|
|
||||||
AvailablePlans: models.GetDefaultPlans(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test JSON serialization
|
|
||||||
data, err := json.Marshal(response)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal BillingStatusResponse: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify it's valid JSON
|
|
||||||
var decoded map[string]interface{}
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Response is not valid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check required fields exist
|
|
||||||
if _, ok := decoded["has_subscription"]; !ok {
|
|
||||||
t.Error("Response should have 'has_subscription' field")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStartTrialRequest_Validation(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
request models.StartTrialRequest
|
|
||||||
wantError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Valid basic plan",
|
|
||||||
request: models.StartTrialRequest{PlanID: models.PlanBasic},
|
|
||||||
wantError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Valid standard plan",
|
|
||||||
request: models.StartTrialRequest{PlanID: models.PlanStandard},
|
|
||||||
wantError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Valid premium plan",
|
|
||||||
request: models.StartTrialRequest{PlanID: models.PlanPremium},
|
|
||||||
wantError: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Test JSON serialization
|
|
||||||
data, err := json.Marshal(tt.request)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal request: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded models.StartTrialRequest
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to unmarshal request: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if decoded.PlanID != tt.request.PlanID {
|
|
||||||
t.Errorf("PlanID mismatch: got %s, expected %s", decoded.PlanID, tt.request.PlanID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChangePlanRequest_Structure(t *testing.T) {
|
|
||||||
request := models.ChangePlanRequest{
|
|
||||||
NewPlanID: models.PlanPremium,
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.Marshal(request)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal ChangePlanRequest: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded map[string]interface{}
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Response is not valid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := decoded["new_plan_id"]; !ok {
|
|
||||||
t.Error("Request should have 'new_plan_id' field")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStartTrialResponse_Structure(t *testing.T) {
|
|
||||||
response := models.StartTrialResponse{
|
|
||||||
CheckoutURL: "https://checkout.stripe.com/c/pay/cs_test_123",
|
|
||||||
SessionID: "cs_test_123",
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.Marshal(response)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal StartTrialResponse: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded map[string]interface{}
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Response is not valid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := decoded["checkout_url"]; !ok {
|
|
||||||
t.Error("Response should have 'checkout_url' field")
|
|
||||||
}
|
|
||||||
if _, ok := decoded["session_id"]; !ok {
|
|
||||||
t.Error("Response should have 'session_id' field")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCancelSubscriptionResponse_Structure(t *testing.T) {
|
|
||||||
response := models.CancelSubscriptionResponse{
|
|
||||||
Success: true,
|
|
||||||
Message: "Subscription will be canceled at the end of the billing period",
|
|
||||||
CancelDate: "2025-01-16",
|
|
||||||
ActiveUntil: "2025-01-16",
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := json.Marshal(response)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal CancelSubscriptionResponse: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !response.Success {
|
|
||||||
t.Error("Success should be true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCustomerPortalResponse_Structure(t *testing.T) {
|
|
||||||
response := models.CustomerPortalResponse{
|
|
||||||
PortalURL: "https://billing.stripe.com/p/session/test_123",
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.Marshal(response)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal CustomerPortalResponse: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded map[string]interface{}
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Response is not valid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := decoded["portal_url"]; !ok {
|
|
||||||
t.Error("Response should have 'portal_url' field")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEntitlementCheckResponse_Structure(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
response models.EntitlementCheckResponse
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Has entitlement",
|
|
||||||
response: models.EntitlementCheckResponse{
|
|
||||||
HasEntitlement: true,
|
|
||||||
PlanID: models.PlanStandard,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "No entitlement",
|
|
||||||
response: models.EntitlementCheckResponse{
|
|
||||||
HasEntitlement: false,
|
|
||||||
PlanID: models.PlanBasic,
|
|
||||||
Message: "Feature not available in this plan",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
data, err := json.Marshal(tt.response)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal EntitlementCheckResponse: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded map[string]interface{}
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Response is not valid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := decoded["has_entitlement"]; !ok {
|
|
||||||
t.Error("Response should have 'has_entitlement' field")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTrackUsageRequest_Validation(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
request models.TrackUsageRequest
|
|
||||||
valid bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Valid AI request",
|
|
||||||
request: models.TrackUsageRequest{
|
|
||||||
UserID: "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
UsageType: "ai_request",
|
|
||||||
Quantity: 1,
|
|
||||||
},
|
|
||||||
valid: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Valid document created",
|
|
||||||
request: models.TrackUsageRequest{
|
|
||||||
UserID: "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
UsageType: "document_created",
|
|
||||||
Quantity: 1,
|
|
||||||
},
|
|
||||||
valid: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Multiple quantity",
|
|
||||||
request: models.TrackUsageRequest{
|
|
||||||
UserID: "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
UsageType: "ai_request",
|
|
||||||
Quantity: 5,
|
|
||||||
},
|
|
||||||
valid: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
data, err := json.Marshal(tt.request)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal TrackUsageRequest: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded models.TrackUsageRequest
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to unmarshal TrackUsageRequest: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if decoded.UserID != tt.request.UserID {
|
|
||||||
t.Errorf("UserID mismatch: got %s, expected %s", decoded.UserID, tt.request.UserID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckUsageResponse_Format(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
response models.CheckUsageResponse
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Allowed response",
|
|
||||||
response: models.CheckUsageResponse{
|
|
||||||
Allowed: true,
|
|
||||||
CurrentUsage: 450,
|
|
||||||
Limit: 1500,
|
|
||||||
Remaining: 1050,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Limit reached",
|
|
||||||
response: models.CheckUsageResponse{
|
|
||||||
Allowed: false,
|
|
||||||
CurrentUsage: 1500,
|
|
||||||
Limit: 1500,
|
|
||||||
Remaining: 0,
|
|
||||||
Message: "Usage limit reached for ai_request (1500/1500)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
data, err := json.Marshal(tt.response)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal CheckUsageResponse: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded map[string]interface{}
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Response is not valid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := decoded["allowed"]; !ok {
|
|
||||||
t.Error("Response should have 'allowed' field")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsumeTaskRequest_Format(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
request models.ConsumeTaskRequest
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Correction task",
|
|
||||||
request: models.ConsumeTaskRequest{
|
|
||||||
UserID: "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
TaskType: models.TaskTypeCorrection,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Letter task",
|
|
||||||
request: models.ConsumeTaskRequest{
|
|
||||||
UserID: "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
TaskType: models.TaskTypeLetter,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Batch task",
|
|
||||||
request: models.ConsumeTaskRequest{
|
|
||||||
UserID: "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
TaskType: models.TaskTypeBatch,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
data, err := json.Marshal(tt.request)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal ConsumeTaskRequest: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded models.ConsumeTaskRequest
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to unmarshal ConsumeTaskRequest: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if decoded.TaskType != tt.request.TaskType {
|
|
||||||
t.Errorf("TaskType mismatch: got %s, expected %s", decoded.TaskType, tt.request.TaskType)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsumeTaskResponse_Format(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
response models.ConsumeTaskResponse
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Successful consumption",
|
|
||||||
response: models.ConsumeTaskResponse{
|
|
||||||
Success: true,
|
|
||||||
TaskID: "task-uuid-123",
|
|
||||||
TasksRemaining: 49,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Limit reached",
|
|
||||||
response: models.ConsumeTaskResponse{
|
|
||||||
Success: false,
|
|
||||||
TasksRemaining: 0,
|
|
||||||
Message: "Dein Aufgaben-Kontingent ist aufgebraucht.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
data, err := json.Marshal(tt.response)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal ConsumeTaskResponse: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded map[string]interface{}
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Response is not valid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := decoded["success"]; !ok {
|
|
||||||
t.Error("Response should have 'success' field")
|
|
||||||
}
|
|
||||||
if _, ok := decoded["tasks_remaining"]; !ok {
|
|
||||||
t.Error("Response should have 'tasks_remaining' field")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckTaskAllowedResponse_Format(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
response models.CheckTaskAllowedResponse
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Task allowed",
|
|
||||||
response: models.CheckTaskAllowedResponse{
|
|
||||||
Allowed: true,
|
|
||||||
TasksAvailable: 50,
|
|
||||||
MaxTasks: 150,
|
|
||||||
PlanID: models.PlanBasic,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Task not allowed",
|
|
||||||
response: models.CheckTaskAllowedResponse{
|
|
||||||
Allowed: false,
|
|
||||||
TasksAvailable: 0,
|
|
||||||
MaxTasks: 150,
|
|
||||||
PlanID: models.PlanBasic,
|
|
||||||
Message: "Dein Aufgaben-Kontingent ist aufgebraucht.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Premium Fair Use",
|
|
||||||
response: models.CheckTaskAllowedResponse{
|
|
||||||
Allowed: true,
|
|
||||||
TasksAvailable: 1000,
|
|
||||||
MaxTasks: 5000,
|
|
||||||
PlanID: models.PlanPremium,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
data, err := json.Marshal(tt.response)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal CheckTaskAllowedResponse: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded map[string]interface{}
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Response is not valid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := decoded["allowed"]; !ok {
|
|
||||||
t.Error("Response should have 'allowed' field")
|
|
||||||
}
|
|
||||||
if _, ok := decoded["tasks_available"]; !ok {
|
|
||||||
t.Error("Response should have 'tasks_available' field")
|
|
||||||
}
|
|
||||||
if _, ok := decoded["plan_id"]; !ok {
|
|
||||||
t.Error("Response should have 'plan_id' field")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTP Handler Tests (without DB)
|
|
||||||
|
|
||||||
func TestHTTPErrorResponse_Format(t *testing.T) {
|
|
||||||
// Test standard error response format
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
|
|
||||||
// Simulate an error response
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
|
||||||
"error": "unauthorized",
|
|
||||||
"message": "User not authenticated",
|
|
||||||
})
|
|
||||||
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("Expected status 401, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var response map[string]interface{}
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := response["error"]; !ok {
|
|
||||||
t.Error("Error response should have 'error' field")
|
|
||||||
}
|
|
||||||
if _, ok := response["message"]; !ok {
|
|
||||||
t.Error("Error response should have 'message' field")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHTTPSuccessResponse_Format(t *testing.T) {
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
|
|
||||||
// Simulate a success response
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": true,
|
|
||||||
"message": "Operation completed",
|
|
||||||
})
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Errorf("Expected status 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var response map[string]interface{}
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if response["success"] != true {
|
|
||||||
t.Error("Success response should have success=true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestParsing_InvalidJSON(t *testing.T) {
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
|
|
||||||
// Create request with invalid JSON
|
|
||||||
invalidJSON := []byte(`{"plan_id": }`) // Invalid JSON
|
|
||||||
c.Request = httptest.NewRequest("POST", "/test", bytes.NewReader(invalidJSON))
|
|
||||||
c.Request.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
var req models.StartTrialRequest
|
|
||||||
err := c.ShouldBindJSON(&req)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Should return error for invalid JSON")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHTTPHeaders_ContentType(t *testing.T) {
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"test": "value"})
|
|
||||||
|
|
||||||
contentType := w.Header().Get("Content-Type")
|
|
||||||
if contentType != "application/json; charset=utf-8" {
|
|
||||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/breakpilot/billing-service/internal/database"
|
|
||||||
"github.com/breakpilot/billing-service/internal/services"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/stripe/stripe-go/v76/webhook"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WebhookHandler handles Stripe webhook events
|
|
||||||
type WebhookHandler struct {
|
|
||||||
db *database.DB
|
|
||||||
webhookSecret string
|
|
||||||
subscriptionService *services.SubscriptionService
|
|
||||||
entitlementService *services.EntitlementService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWebhookHandler creates a new WebhookHandler
|
|
||||||
func NewWebhookHandler(
|
|
||||||
db *database.DB,
|
|
||||||
webhookSecret string,
|
|
||||||
subscriptionService *services.SubscriptionService,
|
|
||||||
entitlementService *services.EntitlementService,
|
|
||||||
) *WebhookHandler {
|
|
||||||
return &WebhookHandler{
|
|
||||||
db: db,
|
|
||||||
webhookSecret: webhookSecret,
|
|
||||||
subscriptionService: subscriptionService,
|
|
||||||
entitlementService: entitlementService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleStripeWebhook handles incoming Stripe webhook events
|
|
||||||
// POST /api/v1/billing/webhook
|
|
||||||
func (h *WebhookHandler) HandleStripeWebhook(c *gin.Context) {
|
|
||||||
// Read the request body
|
|
||||||
body, err := io.ReadAll(c.Request.Body)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Webhook: Error reading body: %v", err)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot read body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the Stripe signature header
|
|
||||||
sigHeader := c.GetHeader("Stripe-Signature")
|
|
||||||
if sigHeader == "" {
|
|
||||||
log.Printf("Webhook: Missing Stripe-Signature header")
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing signature"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the webhook signature
|
|
||||||
event, err := webhook.ConstructEvent(body, sigHeader, h.webhookSecret)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Webhook: Signature verification failed: %v", err)
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
// Check if we've already processed this event (idempotency)
|
|
||||||
processed, err := h.subscriptionService.IsEventProcessed(ctx, event.ID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Webhook: Error checking event: %v", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if processed {
|
|
||||||
log.Printf("Webhook: Event %s already processed", event.ID)
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "already_processed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark event as being processed
|
|
||||||
if err := h.subscriptionService.MarkEventProcessing(ctx, event.ID, string(event.Type)); err != nil {
|
|
||||||
log.Printf("Webhook: Error marking event: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle the event based on type
|
|
||||||
var handleErr error
|
|
||||||
switch event.Type {
|
|
||||||
case "checkout.session.completed":
|
|
||||||
handleErr = h.handleCheckoutSessionCompleted(ctx, event.Data.Raw)
|
|
||||||
|
|
||||||
case "customer.subscription.created":
|
|
||||||
handleErr = h.handleSubscriptionCreated(ctx, event.Data.Raw)
|
|
||||||
|
|
||||||
case "customer.subscription.updated":
|
|
||||||
handleErr = h.handleSubscriptionUpdated(ctx, event.Data.Raw)
|
|
||||||
|
|
||||||
case "customer.subscription.deleted":
|
|
||||||
handleErr = h.handleSubscriptionDeleted(ctx, event.Data.Raw)
|
|
||||||
|
|
||||||
case "invoice.paid":
|
|
||||||
handleErr = h.handleInvoicePaid(ctx, event.Data.Raw)
|
|
||||||
|
|
||||||
case "invoice.payment_failed":
|
|
||||||
handleErr = h.handleInvoicePaymentFailed(ctx, event.Data.Raw)
|
|
||||||
|
|
||||||
case "customer.created":
|
|
||||||
log.Printf("Webhook: Customer created - %s", event.ID)
|
|
||||||
|
|
||||||
default:
|
|
||||||
log.Printf("Webhook: Unhandled event type: %s", event.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
if handleErr != nil {
|
|
||||||
log.Printf("Webhook: Error handling %s: %v", event.Type, handleErr)
|
|
||||||
// Mark event as failed
|
|
||||||
h.subscriptionService.MarkEventFailed(ctx, event.ID, handleErr.Error())
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark event as processed
|
|
||||||
if err := h.subscriptionService.MarkEventProcessed(ctx, event.ID); err != nil {
|
|
||||||
log.Printf("Webhook: Error marking event processed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "processed"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCheckoutSessionCompleted handles successful checkout
|
|
||||||
func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx interface{}, data []byte) error {
|
|
||||||
log.Printf("Webhook: Processing checkout.session.completed")
|
|
||||||
|
|
||||||
// Parse checkout session from data
|
|
||||||
// The actual implementation will parse the JSON and create/update subscription
|
|
||||||
|
|
||||||
// TODO: Implementation
|
|
||||||
// 1. Parse checkout session data
|
|
||||||
// 2. Extract customer_id, subscription_id, user_id (from metadata)
|
|
||||||
// 3. Create or update subscription record
|
|
||||||
// 4. Update entitlements
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSubscriptionCreated handles new subscription creation
|
|
||||||
func (h *WebhookHandler) handleSubscriptionCreated(ctx interface{}, data []byte) error {
|
|
||||||
log.Printf("Webhook: Processing customer.subscription.created")
|
|
||||||
|
|
||||||
// TODO: Implementation
|
|
||||||
// 1. Parse subscription data
|
|
||||||
// 2. Extract status, plan, trial_end, etc.
|
|
||||||
// 3. Create subscription record
|
|
||||||
// 4. Set up initial entitlements
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSubscriptionUpdated handles subscription updates
|
|
||||||
func (h *WebhookHandler) handleSubscriptionUpdated(ctx interface{}, data []byte) error {
|
|
||||||
log.Printf("Webhook: Processing customer.subscription.updated")
|
|
||||||
|
|
||||||
// TODO: Implementation
|
|
||||||
// 1. Parse subscription data
|
|
||||||
// 2. Update subscription record (status, plan, cancel_at_period_end, etc.)
|
|
||||||
// 3. Update entitlements if plan changed
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSubscriptionDeleted handles subscription cancellation
|
|
||||||
func (h *WebhookHandler) handleSubscriptionDeleted(ctx interface{}, data []byte) error {
|
|
||||||
log.Printf("Webhook: Processing customer.subscription.deleted")
|
|
||||||
|
|
||||||
// TODO: Implementation
|
|
||||||
// 1. Parse subscription data
|
|
||||||
// 2. Update subscription status to canceled/expired
|
|
||||||
// 3. Remove or downgrade entitlements
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleInvoicePaid handles successful invoice payment
|
|
||||||
func (h *WebhookHandler) handleInvoicePaid(ctx interface{}, data []byte) error {
|
|
||||||
log.Printf("Webhook: Processing invoice.paid")
|
|
||||||
|
|
||||||
// TODO: Implementation
|
|
||||||
// 1. Parse invoice data
|
|
||||||
// 2. Update subscription period
|
|
||||||
// 3. Reset usage counters for new period
|
|
||||||
// 4. Store invoice record
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleInvoicePaymentFailed handles failed invoice payment
|
|
||||||
func (h *WebhookHandler) handleInvoicePaymentFailed(ctx interface{}, data []byte) error {
|
|
||||||
log.Printf("Webhook: Processing invoice.payment_failed")
|
|
||||||
|
|
||||||
// TODO: Implementation
|
|
||||||
// 1. Parse invoice data
|
|
||||||
// 2. Update subscription status to past_due
|
|
||||||
// 3. Send notification to user
|
|
||||||
// 4. Possibly restrict access
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestWebhookEventTypes tests the event types we handle
|
|
||||||
func TestWebhookEventTypes(t *testing.T) {
|
|
||||||
eventTypes := []struct {
|
|
||||||
eventType string
|
|
||||||
shouldHandle bool
|
|
||||||
}{
|
|
||||||
{"checkout.session.completed", true},
|
|
||||||
{"customer.subscription.created", true},
|
|
||||||
{"customer.subscription.updated", true},
|
|
||||||
{"customer.subscription.deleted", true},
|
|
||||||
{"invoice.paid", true},
|
|
||||||
{"invoice.payment_failed", true},
|
|
||||||
{"customer.created", true}, // Handled but just logged
|
|
||||||
{"unknown.event.type", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range eventTypes {
|
|
||||||
t.Run(tt.eventType, func(t *testing.T) {
|
|
||||||
if tt.eventType == "" {
|
|
||||||
t.Error("Event type should not be empty")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookRequest_MissingSignature tests handling of missing signature
|
|
||||||
func TestWebhookRequest_MissingSignature(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
|
|
||||||
// Create request without Stripe-Signature header
|
|
||||||
body := []byte(`{"id": "evt_test_123", "type": "test.event"}`)
|
|
||||||
c.Request = httptest.NewRequest("POST", "/webhook", bytes.NewReader(body))
|
|
||||||
c.Request.Header.Set("Content-Type", "application/json")
|
|
||||||
// Note: No Stripe-Signature header
|
|
||||||
|
|
||||||
// Simulate the check we do in the handler
|
|
||||||
sigHeader := c.GetHeader("Stripe-Signature")
|
|
||||||
if sigHeader == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing signature"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("Expected status 400 for missing signature, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var response map[string]interface{}
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if response["error"] != "missing signature" {
|
|
||||||
t.Errorf("Expected 'missing signature' error, got '%v'", response["error"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookRequest_EmptyBody tests handling of empty request body
|
|
||||||
func TestWebhookRequest_EmptyBody(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
|
|
||||||
// Create request with empty body
|
|
||||||
c.Request = httptest.NewRequest("POST", "/webhook", bytes.NewReader([]byte{}))
|
|
||||||
c.Request.Header.Set("Content-Type", "application/json")
|
|
||||||
c.Request.Header.Set("Stripe-Signature", "t=123,v1=signature")
|
|
||||||
|
|
||||||
// Read the body
|
|
||||||
body := make([]byte, 0)
|
|
||||||
|
|
||||||
// Simulate empty body handling
|
|
||||||
if len(body) == 0 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "empty body"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("Expected status 400 for empty body, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookIdempotency tests idempotency behavior
|
|
||||||
func TestWebhookIdempotency(t *testing.T) {
|
|
||||||
// Test that the same event ID should not be processed twice
|
|
||||||
eventID := "evt_test_123456789"
|
|
||||||
|
|
||||||
// Simulate event tracking
|
|
||||||
processedEvents := make(map[string]bool)
|
|
||||||
|
|
||||||
// First time - should process
|
|
||||||
if !processedEvents[eventID] {
|
|
||||||
processedEvents[eventID] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second time - should skip
|
|
||||||
alreadyProcessed := processedEvents[eventID]
|
|
||||||
if !alreadyProcessed {
|
|
||||||
t.Error("Event should be marked as processed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookResponse_Processed tests successful webhook response
|
|
||||||
func TestWebhookResponse_Processed(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "processed"})
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Errorf("Expected status 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var response map[string]interface{}
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if response["status"] != "processed" {
|
|
||||||
t.Errorf("Expected status 'processed', got '%v'", response["status"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookResponse_AlreadyProcessed tests idempotent response
|
|
||||||
func TestWebhookResponse_AlreadyProcessed(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "already_processed"})
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Errorf("Expected status 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var response map[string]interface{}
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if response["status"] != "already_processed" {
|
|
||||||
t.Errorf("Expected status 'already_processed', got '%v'", response["status"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookResponse_InternalError tests error response
|
|
||||||
func TestWebhookResponse_InternalError(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler error"})
|
|
||||||
|
|
||||||
if w.Code != http.StatusInternalServerError {
|
|
||||||
t.Errorf("Expected status 500, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var response map[string]interface{}
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if response["error"] != "handler error" {
|
|
||||||
t.Errorf("Expected 'handler error', got '%v'", response["error"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookResponse_InvalidSignature tests signature verification failure
|
|
||||||
func TestWebhookResponse_InvalidSignature(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"})
|
|
||||||
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("Expected status 401, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var response map[string]interface{}
|
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if response["error"] != "invalid signature" {
|
|
||||||
t.Errorf("Expected 'invalid signature', got '%v'", response["error"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCheckoutSessionCompleted_EventStructure tests the event data structure
|
|
||||||
func TestCheckoutSessionCompleted_EventStructure(t *testing.T) {
|
|
||||||
// Test the expected structure of a checkout.session.completed event
|
|
||||||
eventData := map[string]interface{}{
|
|
||||||
"id": "cs_test_123",
|
|
||||||
"customer": "cus_test_456",
|
|
||||||
"subscription": "sub_test_789",
|
|
||||||
"mode": "subscription",
|
|
||||||
"payment_status": "paid",
|
|
||||||
"status": "complete",
|
|
||||||
"metadata": map[string]interface{}{
|
|
||||||
"user_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"plan_id": "standard",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.Marshal(eventData)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal event data: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded map[string]interface{}
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to unmarshal event data: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify required fields
|
|
||||||
if decoded["customer"] == nil {
|
|
||||||
t.Error("Event should have 'customer' field")
|
|
||||||
}
|
|
||||||
if decoded["subscription"] == nil {
|
|
||||||
t.Error("Event should have 'subscription' field")
|
|
||||||
}
|
|
||||||
metadata, ok := decoded["metadata"].(map[string]interface{})
|
|
||||||
if !ok || metadata["user_id"] == nil {
|
|
||||||
t.Error("Event should have 'metadata.user_id' field")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSubscriptionCreated_EventStructure tests subscription.created event structure
|
|
||||||
func TestSubscriptionCreated_EventStructure(t *testing.T) {
|
|
||||||
eventData := map[string]interface{}{
|
|
||||||
"id": "sub_test_123",
|
|
||||||
"customer": "cus_test_456",
|
|
||||||
"status": "trialing",
|
|
||||||
"items": map[string]interface{}{
|
|
||||||
"data": []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"price": map[string]interface{}{
|
|
||||||
"id": "price_test_789",
|
|
||||||
"metadata": map[string]interface{}{"plan_id": "standard"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"trial_end": 1735689600,
|
|
||||||
"current_period_end": 1735689600,
|
|
||||||
"metadata": map[string]interface{}{
|
|
||||||
"user_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"plan_id": "standard",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.Marshal(eventData)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal event data: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded map[string]interface{}
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to unmarshal event data: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify required fields
|
|
||||||
if decoded["status"] != "trialing" {
|
|
||||||
t.Errorf("Expected status 'trialing', got '%v'", decoded["status"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSubscriptionUpdated_StatusTransitions tests subscription status transitions
|
|
||||||
func TestSubscriptionUpdated_StatusTransitions(t *testing.T) {
|
|
||||||
validTransitions := []struct {
|
|
||||||
from string
|
|
||||||
to string
|
|
||||||
}{
|
|
||||||
{"trialing", "active"},
|
|
||||||
{"active", "past_due"},
|
|
||||||
{"past_due", "active"},
|
|
||||||
{"active", "canceled"},
|
|
||||||
{"trialing", "canceled"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range validTransitions {
|
|
||||||
t.Run(tt.from+"->"+tt.to, func(t *testing.T) {
|
|
||||||
if tt.from == "" || tt.to == "" {
|
|
||||||
t.Error("Status should not be empty")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInvoicePaid_EventStructure tests invoice.paid event structure
|
|
||||||
func TestInvoicePaid_EventStructure(t *testing.T) {
|
|
||||||
eventData := map[string]interface{}{
|
|
||||||
"id": "in_test_123",
|
|
||||||
"subscription": "sub_test_456",
|
|
||||||
"customer": "cus_test_789",
|
|
||||||
"status": "paid",
|
|
||||||
"amount_paid": 1990,
|
|
||||||
"currency": "eur",
|
|
||||||
"period_start": 1735689600,
|
|
||||||
"period_end": 1738368000,
|
|
||||||
"hosted_invoice_url": "https://invoice.stripe.com/test",
|
|
||||||
"invoice_pdf": "https://invoice.stripe.com/test.pdf",
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.Marshal(eventData)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal event data: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded map[string]interface{}
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to unmarshal event data: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify required fields
|
|
||||||
if decoded["status"] != "paid" {
|
|
||||||
t.Errorf("Expected status 'paid', got '%v'", decoded["status"])
|
|
||||||
}
|
|
||||||
if decoded["subscription"] == nil {
|
|
||||||
t.Error("Event should have 'subscription' field")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInvoicePaymentFailed_EventStructure tests invoice.payment_failed event structure
|
|
||||||
func TestInvoicePaymentFailed_EventStructure(t *testing.T) {
|
|
||||||
eventData := map[string]interface{}{
|
|
||||||
"id": "in_test_123",
|
|
||||||
"subscription": "sub_test_456",
|
|
||||||
"customer": "cus_test_789",
|
|
||||||
"status": "open",
|
|
||||||
"attempt_count": 1,
|
|
||||||
"next_payment_attempt": 1735776000,
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.Marshal(eventData)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal event data: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded map[string]interface{}
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to unmarshal event data: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify fields
|
|
||||||
if decoded["attempt_count"] == nil {
|
|
||||||
t.Error("Event should have 'attempt_count' field")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSubscriptionDeleted_EventStructure tests subscription.deleted event structure
|
|
||||||
func TestSubscriptionDeleted_EventStructure(t *testing.T) {
|
|
||||||
eventData := map[string]interface{}{
|
|
||||||
"id": "sub_test_123",
|
|
||||||
"customer": "cus_test_456",
|
|
||||||
"status": "canceled",
|
|
||||||
"ended_at": 1735689600,
|
|
||||||
"canceled_at": 1735689600,
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.Marshal(eventData)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal event data: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded map[string]interface{}
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to unmarshal event data: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify required fields
|
|
||||||
if decoded["status"] != "canceled" {
|
|
||||||
t.Errorf("Expected status 'canceled', got '%v'", decoded["status"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStripeSignatureFormat tests the Stripe signature header format
|
|
||||||
func TestStripeSignatureFormat(t *testing.T) {
|
|
||||||
// Stripe signature format: t=timestamp,v1=signature
|
|
||||||
validSignatures := []string{
|
|
||||||
"t=1609459200,v1=abc123def456",
|
|
||||||
"t=1609459200,v1=signature_here,v0=old_signature",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, sig := range validSignatures {
|
|
||||||
if len(sig) < 10 {
|
|
||||||
t.Errorf("Signature seems too short: %s", sig)
|
|
||||||
}
|
|
||||||
// Should start with timestamp
|
|
||||||
if sig[:2] != "t=" {
|
|
||||||
t.Errorf("Signature should start with 't=': %s", sig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookEventID_Format tests Stripe event ID format
|
|
||||||
func TestWebhookEventID_Format(t *testing.T) {
|
|
||||||
validEventIDs := []string{
|
|
||||||
"evt_1234567890abcdef",
|
|
||||||
"evt_test_123456789",
|
|
||||||
"evt_live_987654321",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, eventID := range validEventIDs {
|
|
||||||
// Event IDs should start with "evt_"
|
|
||||||
if len(eventID) < 10 || eventID[:4] != "evt_" {
|
|
||||||
t.Errorf("Invalid event ID format: %s", eventID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// UserClaims represents the JWT claims for a user
|
|
||||||
type UserClaims struct {
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
// CORS returns a CORS middleware
|
|
||||||
func CORS() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
origin := c.Request.Header.Get("Origin")
|
|
||||||
|
|
||||||
// Allow localhost for development
|
|
||||||
allowedOrigins := []string{
|
|
||||||
"http://localhost:3000",
|
|
||||||
"http://localhost:8000",
|
|
||||||
"http://localhost:8080",
|
|
||||||
"http://localhost:8083",
|
|
||||||
"https://breakpilot.app",
|
|
||||||
}
|
|
||||||
|
|
||||||
allowed := false
|
|
||||||
for _, o := range allowedOrigins {
|
|
||||||
if origin == o {
|
|
||||||
allowed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if allowed {
|
|
||||||
c.Header("Access-Control-Allow-Origin", origin)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
||||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-Requested-With, X-Internal-API-Key")
|
|
||||||
c.Header("Access-Control-Allow-Credentials", "true")
|
|
||||||
c.Header("Access-Control-Max-Age", "86400")
|
|
||||||
|
|
||||||
if c.Request.Method == "OPTIONS" {
|
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequestLogger logs each request
|
|
||||||
func RequestLogger() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
start := time.Now()
|
|
||||||
path := c.Request.URL.Path
|
|
||||||
method := c.Request.Method
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
|
|
||||||
latency := time.Since(start)
|
|
||||||
status := c.Writer.Status()
|
|
||||||
|
|
||||||
// Log only in development or for errors
|
|
||||||
if status >= 400 {
|
|
||||||
gin.DefaultWriter.Write([]byte(
|
|
||||||
method + " " + path + " " +
|
|
||||||
string(rune(status)) + " " +
|
|
||||||
latency.String() + "\n",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RateLimiter implements a simple in-memory rate limiter
|
|
||||||
func RateLimiter() gin.HandlerFunc {
|
|
||||||
type client struct {
|
|
||||||
count int
|
|
||||||
lastSeen time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
clients = make(map[string]*client)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Clean up old entries periodically
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
time.Sleep(time.Minute)
|
|
||||||
mu.Lock()
|
|
||||||
for ip, c := range clients {
|
|
||||||
if time.Since(c.lastSeen) > time.Minute {
|
|
||||||
delete(clients, ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
ip := c.ClientIP()
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
if _, exists := clients[ip]; !exists {
|
|
||||||
clients[ip] = &client{}
|
|
||||||
}
|
|
||||||
|
|
||||||
cli := clients[ip]
|
|
||||||
|
|
||||||
// Reset count if more than a minute has passed
|
|
||||||
if time.Since(cli.lastSeen) > time.Minute {
|
|
||||||
cli.count = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
cli.count++
|
|
||||||
cli.lastSeen = time.Now()
|
|
||||||
|
|
||||||
// Allow 100 requests per minute
|
|
||||||
if cli.count > 100 {
|
|
||||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
|
||||||
"error": "rate_limit_exceeded",
|
|
||||||
"message": "Too many requests. Please try again later.",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthMiddleware validates JWT tokens
|
|
||||||
func AuthMiddleware(jwtSecret string) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
authHeader := c.GetHeader("Authorization")
|
|
||||||
if authHeader == "" {
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
|
||||||
"error": "missing_authorization",
|
|
||||||
"message": "Authorization header is required",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract token from "Bearer <token>"
|
|
||||||
parts := strings.Split(authHeader, " ")
|
|
||||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
|
||||||
"error": "invalid_authorization",
|
|
||||||
"message": "Authorization header must be in format: Bearer <token>",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenString := parts[1]
|
|
||||||
|
|
||||||
// Parse and validate token
|
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &UserClaims{}, func(token *jwt.Token) (interface{}, error) {
|
|
||||||
return []byte(jwtSecret), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
|
||||||
"error": "invalid_token",
|
|
||||||
"message": "Invalid or expired token",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if claims, ok := token.Claims.(*UserClaims); ok && token.Valid {
|
|
||||||
// Set user info in context
|
|
||||||
c.Set("user_id", claims.UserID)
|
|
||||||
c.Set("email", claims.Email)
|
|
||||||
c.Set("role", claims.Role)
|
|
||||||
c.Next()
|
|
||||||
} else {
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
|
||||||
"error": "invalid_claims",
|
|
||||||
"message": "Invalid token claims",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// InternalAPIKeyMiddleware validates internal API key for service-to-service communication
|
|
||||||
func InternalAPIKeyMiddleware(apiKey string) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if apiKey == "" {
|
|
||||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "config_error",
|
|
||||||
"message": "Internal API key not configured",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
providedKey := c.GetHeader("X-Internal-API-Key")
|
|
||||||
if providedKey == "" {
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
|
||||||
"error": "missing_api_key",
|
|
||||||
"message": "X-Internal-API-Key header is required",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if providedKey != apiKey {
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
|
||||||
"error": "invalid_api_key",
|
|
||||||
"message": "Invalid API key",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminOnly ensures only admin users can access the route
|
|
||||||
func AdminOnly() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
role, exists := c.Get("role")
|
|
||||||
if !exists {
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
|
||||||
"error": "unauthorized",
|
|
||||||
"message": "User role not found",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
roleStr, ok := role.(string)
|
|
||||||
if !ok || (roleStr != "admin" && roleStr != "super_admin" && roleStr != "data_protection_officer") {
|
|
||||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
|
||||||
"error": "forbidden",
|
|
||||||
"message": "Admin access required",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserID extracts the user ID from the context
|
|
||||||
func GetUserID(c *gin.Context) (uuid.UUID, error) {
|
|
||||||
userIDStr, exists := c.Get("user_id")
|
|
||||||
if !exists {
|
|
||||||
return uuid.Nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, err := uuid.Parse(userIDStr.(string))
|
|
||||||
if err != nil {
|
|
||||||
return uuid.Nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return userID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClientIP returns the client's IP address
|
|
||||||
func GetClientIP(c *gin.Context) string {
|
|
||||||
// Check X-Forwarded-For header first (for proxied requests)
|
|
||||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
|
||||||
ips := strings.Split(xff, ",")
|
|
||||||
return strings.TrimSpace(ips[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check X-Real-IP header
|
|
||||||
if xri := c.GetHeader("X-Real-IP"); xri != "" {
|
|
||||||
return xri
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.ClientIP()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserAgent returns the client's User-Agent
|
|
||||||
func GetUserAgent(c *gin.Context) string {
|
|
||||||
return c.GetHeader("User-Agent")
|
|
||||||
}
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SubscriptionStatus represents the status of a subscription
|
|
||||||
type SubscriptionStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
StatusTrialing SubscriptionStatus = "trialing"
|
|
||||||
StatusActive SubscriptionStatus = "active"
|
|
||||||
StatusPastDue SubscriptionStatus = "past_due"
|
|
||||||
StatusCanceled SubscriptionStatus = "canceled"
|
|
||||||
StatusExpired SubscriptionStatus = "expired"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PlanID represents the available plan IDs
|
|
||||||
type PlanID string
|
|
||||||
|
|
||||||
const (
|
|
||||||
PlanBasic PlanID = "basic"
|
|
||||||
PlanStandard PlanID = "standard"
|
|
||||||
PlanPremium PlanID = "premium"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TaskType represents the type of task
|
|
||||||
type TaskType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
TaskTypeCorrection TaskType = "correction"
|
|
||||||
TaskTypeLetter TaskType = "letter"
|
|
||||||
TaskTypeMeeting TaskType = "meeting"
|
|
||||||
TaskTypeBatch TaskType = "batch"
|
|
||||||
TaskTypeOther TaskType = "other"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CarryoverMonthsCap is the maximum number of months tasks can accumulate
|
|
||||||
const CarryoverMonthsCap = 5
|
|
||||||
|
|
||||||
// Subscription represents a user's subscription
|
|
||||||
type Subscription struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
UserID uuid.UUID `json:"user_id"`
|
|
||||||
StripeCustomerID string `json:"stripe_customer_id"`
|
|
||||||
StripeSubscriptionID string `json:"stripe_subscription_id"`
|
|
||||||
PlanID PlanID `json:"plan_id"`
|
|
||||||
Status SubscriptionStatus `json:"status"`
|
|
||||||
TrialEnd *time.Time `json:"trial_end,omitempty"`
|
|
||||||
CurrentPeriodEnd *time.Time `json:"current_period_end,omitempty"`
|
|
||||||
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BillingPlan represents a billing plan with its features and limits
|
|
||||||
type BillingPlan struct {
|
|
||||||
ID PlanID `json:"id"`
|
|
||||||
StripePriceID string `json:"stripe_price_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
PriceCents int `json:"price_cents"` // Price in cents (990 = 9.90 EUR)
|
|
||||||
Currency string `json:"currency"`
|
|
||||||
Interval string `json:"interval"` // "month" or "year"
|
|
||||||
Features PlanFeatures `json:"features"`
|
|
||||||
IsActive bool `json:"is_active"`
|
|
||||||
SortOrder int `json:"sort_order"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanFeatures represents the features and limits of a plan
|
|
||||||
type PlanFeatures struct {
|
|
||||||
// Task-based limits (primary billing unit)
|
|
||||||
MonthlyTaskAllowance int `json:"monthly_task_allowance"` // Tasks per month
|
|
||||||
MaxTaskBalance int `json:"max_task_balance"` // Max accumulated tasks (allowance * CarryoverMonthsCap)
|
|
||||||
|
|
||||||
// Legacy fields for backward compatibility (deprecated, use task-based limits)
|
|
||||||
AIRequestsLimit int `json:"ai_requests_limit,omitempty"`
|
|
||||||
DocumentsLimit int `json:"documents_limit,omitempty"`
|
|
||||||
|
|
||||||
// Feature flags
|
|
||||||
FeatureFlags []string `json:"feature_flags"`
|
|
||||||
MaxTeamMembers int `json:"max_team_members,omitempty"`
|
|
||||||
PrioritySupport bool `json:"priority_support"`
|
|
||||||
CustomBranding bool `json:"custom_branding"`
|
|
||||||
BatchProcessing bool `json:"batch_processing"`
|
|
||||||
CustomTemplates bool `json:"custom_templates"`
|
|
||||||
|
|
||||||
// Premium: Fair Use (no visible limit)
|
|
||||||
FairUseMode bool `json:"fair_use_mode"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Task represents a single task that consumes 1 unit from the balance
|
|
||||||
type Task struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
AccountID uuid.UUID `json:"account_id"`
|
|
||||||
TaskType TaskType `json:"task_type"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
Consumed bool `json:"consumed"` // Always true when created
|
|
||||||
// Internal metrics (not shown to user)
|
|
||||||
PageCount int `json:"-"`
|
|
||||||
TokenCount int `json:"-"`
|
|
||||||
ProcessTime int `json:"-"` // in seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
// AccountUsage represents the task-based usage for an account
|
|
||||||
type AccountUsage struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
AccountID uuid.UUID `json:"account_id"`
|
|
||||||
PlanID PlanID `json:"plan"`
|
|
||||||
MonthlyTaskAllowance int `json:"monthly_task_allowance"`
|
|
||||||
CarryoverMonthsCap int `json:"carryover_months_cap"` // Always 5
|
|
||||||
MaxTaskBalance int `json:"max_task_balance"` // allowance * cap
|
|
||||||
TaskBalance int `json:"task_balance"` // Current available tasks
|
|
||||||
LastRenewalAt time.Time `json:"last_renewal_at"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UsageSummary tracks usage for a specific period (internal metrics)
|
|
||||||
type UsageSummary struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
UserID uuid.UUID `json:"user_id"`
|
|
||||||
UsageType string `json:"usage_type"` // "task", "page", "token"
|
|
||||||
PeriodStart time.Time `json:"period_start"`
|
|
||||||
TotalCount int `json:"total_count"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserEntitlements represents cached entitlements for a user
|
|
||||||
type UserEntitlements struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
UserID uuid.UUID `json:"user_id"`
|
|
||||||
PlanID PlanID `json:"plan_id"`
|
|
||||||
TaskBalance int `json:"task_balance"`
|
|
||||||
MaxBalance int `json:"max_balance"`
|
|
||||||
Features PlanFeatures `json:"features"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
// Legacy fields for backward compatibility with old entitlement service
|
|
||||||
AIRequestsLimit int `json:"ai_requests_limit"`
|
|
||||||
AIRequestsUsed int `json:"ai_requests_used"`
|
|
||||||
DocumentsLimit int `json:"documents_limit"`
|
|
||||||
DocumentsUsed int `json:"documents_used"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StripeWebhookEvent tracks processed webhook events for idempotency
|
|
||||||
type StripeWebhookEvent struct {
|
|
||||||
StripeEventID string `json:"stripe_event_id"`
|
|
||||||
EventType string `json:"event_type"`
|
|
||||||
Processed bool `json:"processed"`
|
|
||||||
ProcessedAt time.Time `json:"processed_at"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BillingStatusResponse is the response for the billing status endpoint
|
|
||||||
type BillingStatusResponse struct {
|
|
||||||
HasSubscription bool `json:"has_subscription"`
|
|
||||||
Subscription *SubscriptionInfo `json:"subscription,omitempty"`
|
|
||||||
TaskUsage *TaskUsageInfo `json:"task_usage,omitempty"`
|
|
||||||
Entitlements *EntitlementInfo `json:"entitlements,omitempty"`
|
|
||||||
AvailablePlans []BillingPlan `json:"available_plans,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubscriptionInfo contains subscription details for the response
|
|
||||||
type SubscriptionInfo struct {
|
|
||||||
PlanID PlanID `json:"plan_id"`
|
|
||||||
PlanName string `json:"plan_name"`
|
|
||||||
Status SubscriptionStatus `json:"status"`
|
|
||||||
IsTrialing bool `json:"is_trialing"`
|
|
||||||
TrialDaysLeft int `json:"trial_days_left,omitempty"`
|
|
||||||
CurrentPeriodEnd *time.Time `json:"current_period_end,omitempty"`
|
|
||||||
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
|
||||||
PriceCents int `json:"price_cents"`
|
|
||||||
Currency string `json:"currency"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TaskUsageInfo contains current task usage information
|
|
||||||
// This is the ONLY usage info shown to users
|
|
||||||
type TaskUsageInfo struct {
|
|
||||||
TasksAvailable int `json:"tasks_available"` // Current balance
|
|
||||||
MaxTasks int `json:"max_tasks"` // Max possible balance
|
|
||||||
InfoText string `json:"info_text"` // "Aufgaben verfuegbar: X von max. Y"
|
|
||||||
TooltipText string `json:"tooltip_text"` // "Aufgaben koennen sich bis zu 5 Monate ansammeln."
|
|
||||||
}
|
|
||||||
|
|
||||||
// EntitlementInfo contains feature entitlements
|
|
||||||
type EntitlementInfo struct {
|
|
||||||
Features []string `json:"features"`
|
|
||||||
MaxTeamMembers int `json:"max_team_members,omitempty"`
|
|
||||||
PrioritySupport bool `json:"priority_support"`
|
|
||||||
CustomBranding bool `json:"custom_branding"`
|
|
||||||
BatchProcessing bool `json:"batch_processing"`
|
|
||||||
CustomTemplates bool `json:"custom_templates"`
|
|
||||||
FairUseMode bool `json:"fair_use_mode"` // Premium only
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartTrialRequest is the request to start a trial
|
|
||||||
type StartTrialRequest struct {
|
|
||||||
PlanID PlanID `json:"plan_id" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartTrialResponse is the response after starting a trial
|
|
||||||
type StartTrialResponse struct {
|
|
||||||
CheckoutURL string `json:"checkout_url"`
|
|
||||||
SessionID string `json:"session_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChangePlanRequest is the request to change plans
|
|
||||||
type ChangePlanRequest struct {
|
|
||||||
NewPlanID PlanID `json:"new_plan_id" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChangePlanResponse is the response after changing plans
|
|
||||||
type ChangePlanResponse struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
EffectiveDate string `json:"effective_date,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CancelSubscriptionResponse is the response after canceling
|
|
||||||
type CancelSubscriptionResponse struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
CancelDate string `json:"cancel_date"`
|
|
||||||
ActiveUntil string `json:"active_until"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CustomerPortalResponse contains the portal URL
|
|
||||||
type CustomerPortalResponse struct {
|
|
||||||
PortalURL string `json:"portal_url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConsumeTaskRequest is the request to consume a task (internal)
|
|
||||||
type ConsumeTaskRequest struct {
|
|
||||||
UserID string `json:"user_id" binding:"required"`
|
|
||||||
TaskType TaskType `json:"task_type" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConsumeTaskResponse is the response after consuming a task
|
|
||||||
type ConsumeTaskResponse struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
TaskID string `json:"task_id,omitempty"`
|
|
||||||
TasksRemaining int `json:"tasks_remaining"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckTaskAllowedResponse is the response for task limit checks
|
|
||||||
type CheckTaskAllowedResponse struct {
|
|
||||||
Allowed bool `json:"allowed"`
|
|
||||||
TasksAvailable int `json:"tasks_available"`
|
|
||||||
MaxTasks int `json:"max_tasks"`
|
|
||||||
PlanID PlanID `json:"plan_id"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// EntitlementCheckResponse is the response for entitlement checks (internal)
|
|
||||||
type EntitlementCheckResponse struct {
|
|
||||||
HasEntitlement bool `json:"has_entitlement"`
|
|
||||||
PlanID PlanID `json:"plan_id,omitempty"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TaskLimitError represents the error when task limit is reached
|
|
||||||
type TaskLimitError struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
CurrentBalance int `json:"current_balance"`
|
|
||||||
Plan PlanID `json:"plan"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UsageInfo represents current usage information (legacy, prefer TaskUsageInfo)
|
|
||||||
type UsageInfo struct {
|
|
||||||
AIRequestsUsed int `json:"ai_requests_used"`
|
|
||||||
AIRequestsLimit int `json:"ai_requests_limit"`
|
|
||||||
AIRequestsPercent float64 `json:"ai_requests_percent"`
|
|
||||||
DocumentsUsed int `json:"documents_used"`
|
|
||||||
DocumentsLimit int `json:"documents_limit"`
|
|
||||||
DocumentsPercent float64 `json:"documents_percent"`
|
|
||||||
PeriodStart string `json:"period_start"`
|
|
||||||
PeriodEnd string `json:"period_end"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckUsageResponse is the response for legacy usage checks
|
|
||||||
type CheckUsageResponse struct {
|
|
||||||
Allowed bool `json:"allowed"`
|
|
||||||
CurrentUsage int `json:"current_usage"`
|
|
||||||
Limit int `json:"limit"`
|
|
||||||
Remaining int `json:"remaining"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TrackUsageRequest is the request to track usage (internal)
|
|
||||||
type TrackUsageRequest struct {
|
|
||||||
UserID string `json:"user_id" binding:"required"`
|
|
||||||
UsageType string `json:"usage_type" binding:"required"`
|
|
||||||
Quantity int `json:"quantity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultPlans returns the default billing plans with task-based limits
|
|
||||||
func GetDefaultPlans() []BillingPlan {
|
|
||||||
return []BillingPlan{
|
|
||||||
{
|
|
||||||
ID: PlanBasic,
|
|
||||||
Name: "Basic",
|
|
||||||
Description: "Perfekt fuer den Einstieg - Gelegentliche Nutzung",
|
|
||||||
PriceCents: 990, // 9.90 EUR
|
|
||||||
Currency: "eur",
|
|
||||||
Interval: "month",
|
|
||||||
Features: PlanFeatures{
|
|
||||||
MonthlyTaskAllowance: 30, // 30 tasks/month
|
|
||||||
MaxTaskBalance: 30 * CarryoverMonthsCap, // 150 max
|
|
||||||
FeatureFlags: []string{"basic_ai", "basic_documents"},
|
|
||||||
MaxTeamMembers: 1,
|
|
||||||
PrioritySupport: false,
|
|
||||||
CustomBranding: false,
|
|
||||||
BatchProcessing: false,
|
|
||||||
CustomTemplates: false,
|
|
||||||
FairUseMode: false,
|
|
||||||
},
|
|
||||||
IsActive: true,
|
|
||||||
SortOrder: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: PlanStandard,
|
|
||||||
Name: "Standard",
|
|
||||||
Description: "Fuer regelmaessige Nutzer - Mehrere Klassen und regelmaessige Korrekturen",
|
|
||||||
PriceCents: 1990, // 19.90 EUR
|
|
||||||
Currency: "eur",
|
|
||||||
Interval: "month",
|
|
||||||
Features: PlanFeatures{
|
|
||||||
MonthlyTaskAllowance: 100, // 100 tasks/month
|
|
||||||
MaxTaskBalance: 100 * CarryoverMonthsCap, // 500 max
|
|
||||||
FeatureFlags: []string{"basic_ai", "basic_documents", "templates", "batch_processing"},
|
|
||||||
MaxTeamMembers: 3,
|
|
||||||
PrioritySupport: false,
|
|
||||||
CustomBranding: false,
|
|
||||||
BatchProcessing: true,
|
|
||||||
CustomTemplates: true,
|
|
||||||
FairUseMode: false,
|
|
||||||
},
|
|
||||||
IsActive: true,
|
|
||||||
SortOrder: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: PlanPremium,
|
|
||||||
Name: "Premium",
|
|
||||||
Description: "Sorglos-Tarif - Vielnutzer, Teams, schulischer Kontext",
|
|
||||||
PriceCents: 3990, // 39.90 EUR
|
|
||||||
Currency: "eur",
|
|
||||||
Interval: "month",
|
|
||||||
Features: PlanFeatures{
|
|
||||||
MonthlyTaskAllowance: 1000, // Very high (Fair Use)
|
|
||||||
MaxTaskBalance: 1000 * CarryoverMonthsCap, // 5000 max (not shown to user)
|
|
||||||
FeatureFlags: []string{"basic_ai", "basic_documents", "templates", "batch_processing", "team_features", "admin_panel", "audit_log", "api_access"},
|
|
||||||
MaxTeamMembers: 10,
|
|
||||||
PrioritySupport: true,
|
|
||||||
CustomBranding: true,
|
|
||||||
BatchProcessing: true,
|
|
||||||
CustomTemplates: true,
|
|
||||||
FairUseMode: true, // No visible limit
|
|
||||||
},
|
|
||||||
IsActive: true,
|
|
||||||
SortOrder: 3,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CalculateMaxTaskBalance calculates max task balance from monthly allowance
|
|
||||||
func CalculateMaxTaskBalance(monthlyAllowance int) int {
|
|
||||||
return monthlyAllowance * CarryoverMonthsCap
|
|
||||||
}
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCarryoverMonthsCap(t *testing.T) {
|
|
||||||
// Verify the constant is set correctly
|
|
||||||
if CarryoverMonthsCap != 5 {
|
|
||||||
t.Errorf("CarryoverMonthsCap should be 5, got %d", CarryoverMonthsCap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculateMaxTaskBalance(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
monthlyAllowance int
|
|
||||||
expected int
|
|
||||||
}{
|
|
||||||
{"Basic plan", 30, 150},
|
|
||||||
{"Standard plan", 100, 500},
|
|
||||||
{"Premium plan", 1000, 5000},
|
|
||||||
{"Zero allowance", 0, 0},
|
|
||||||
{"Single task", 1, 5},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := CalculateMaxTaskBalance(tt.monthlyAllowance)
|
|
||||||
if result != tt.expected {
|
|
||||||
t.Errorf("CalculateMaxTaskBalance(%d) = %d, expected %d",
|
|
||||||
tt.monthlyAllowance, result, tt.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetDefaultPlans(t *testing.T) {
|
|
||||||
plans := GetDefaultPlans()
|
|
||||||
|
|
||||||
if len(plans) != 3 {
|
|
||||||
t.Fatalf("Expected 3 plans, got %d", len(plans))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test Basic plan
|
|
||||||
basic := plans[0]
|
|
||||||
if basic.ID != PlanBasic {
|
|
||||||
t.Errorf("First plan should be Basic, got %s", basic.ID)
|
|
||||||
}
|
|
||||||
if basic.PriceCents != 990 {
|
|
||||||
t.Errorf("Basic price should be 990 cents, got %d", basic.PriceCents)
|
|
||||||
}
|
|
||||||
if basic.Features.MonthlyTaskAllowance != 30 {
|
|
||||||
t.Errorf("Basic monthly allowance should be 30, got %d", basic.Features.MonthlyTaskAllowance)
|
|
||||||
}
|
|
||||||
if basic.Features.MaxTaskBalance != 150 {
|
|
||||||
t.Errorf("Basic max balance should be 150, got %d", basic.Features.MaxTaskBalance)
|
|
||||||
}
|
|
||||||
if basic.Features.FairUseMode {
|
|
||||||
t.Error("Basic should not have FairUseMode")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test Standard plan
|
|
||||||
standard := plans[1]
|
|
||||||
if standard.ID != PlanStandard {
|
|
||||||
t.Errorf("Second plan should be Standard, got %s", standard.ID)
|
|
||||||
}
|
|
||||||
if standard.PriceCents != 1990 {
|
|
||||||
t.Errorf("Standard price should be 1990 cents, got %d", standard.PriceCents)
|
|
||||||
}
|
|
||||||
if standard.Features.MonthlyTaskAllowance != 100 {
|
|
||||||
t.Errorf("Standard monthly allowance should be 100, got %d", standard.Features.MonthlyTaskAllowance)
|
|
||||||
}
|
|
||||||
if !standard.Features.BatchProcessing {
|
|
||||||
t.Error("Standard should have BatchProcessing")
|
|
||||||
}
|
|
||||||
if !standard.Features.CustomTemplates {
|
|
||||||
t.Error("Standard should have CustomTemplates")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test Premium plan
|
|
||||||
premium := plans[2]
|
|
||||||
if premium.ID != PlanPremium {
|
|
||||||
t.Errorf("Third plan should be Premium, got %s", premium.ID)
|
|
||||||
}
|
|
||||||
if premium.PriceCents != 3990 {
|
|
||||||
t.Errorf("Premium price should be 3990 cents, got %d", premium.PriceCents)
|
|
||||||
}
|
|
||||||
if !premium.Features.FairUseMode {
|
|
||||||
t.Error("Premium should have FairUseMode")
|
|
||||||
}
|
|
||||||
if !premium.Features.PrioritySupport {
|
|
||||||
t.Error("Premium should have PrioritySupport")
|
|
||||||
}
|
|
||||||
if !premium.Features.CustomBranding {
|
|
||||||
t.Error("Premium should have CustomBranding")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanIDConstants(t *testing.T) {
|
|
||||||
if PlanBasic != "basic" {
|
|
||||||
t.Errorf("PlanBasic should be 'basic', got '%s'", PlanBasic)
|
|
||||||
}
|
|
||||||
if PlanStandard != "standard" {
|
|
||||||
t.Errorf("PlanStandard should be 'standard', got '%s'", PlanStandard)
|
|
||||||
}
|
|
||||||
if PlanPremium != "premium" {
|
|
||||||
t.Errorf("PlanPremium should be 'premium', got '%s'", PlanPremium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubscriptionStatusConstants(t *testing.T) {
|
|
||||||
statuses := []struct {
|
|
||||||
status SubscriptionStatus
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{StatusTrialing, "trialing"},
|
|
||||||
{StatusActive, "active"},
|
|
||||||
{StatusPastDue, "past_due"},
|
|
||||||
{StatusCanceled, "canceled"},
|
|
||||||
{StatusExpired, "expired"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range statuses {
|
|
||||||
if string(tt.status) != tt.expected {
|
|
||||||
t.Errorf("Status %s should be '%s'", tt.status, tt.expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskTypeConstants(t *testing.T) {
|
|
||||||
types := []struct {
|
|
||||||
taskType TaskType
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{TaskTypeCorrection, "correction"},
|
|
||||||
{TaskTypeLetter, "letter"},
|
|
||||||
{TaskTypeMeeting, "meeting"},
|
|
||||||
{TaskTypeBatch, "batch"},
|
|
||||||
{TaskTypeOther, "other"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range types {
|
|
||||||
if string(tt.taskType) != tt.expected {
|
|
||||||
t.Errorf("TaskType %s should be '%s'", tt.taskType, tt.expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanFeatures_CarryoverCalculation(t *testing.T) {
|
|
||||||
plans := GetDefaultPlans()
|
|
||||||
|
|
||||||
for _, plan := range plans {
|
|
||||||
expectedMax := plan.Features.MonthlyTaskAllowance * CarryoverMonthsCap
|
|
||||||
if plan.Features.MaxTaskBalance != expectedMax {
|
|
||||||
t.Errorf("Plan %s: MaxTaskBalance should be %d (allowance * 5), got %d",
|
|
||||||
plan.ID, expectedMax, plan.Features.MaxTaskBalance)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBillingPlan_AllPlansActive(t *testing.T) {
|
|
||||||
plans := GetDefaultPlans()
|
|
||||||
|
|
||||||
for _, plan := range plans {
|
|
||||||
if !plan.IsActive {
|
|
||||||
t.Errorf("Plan %s should be active", plan.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBillingPlan_CurrencyIsEuro(t *testing.T) {
|
|
||||||
plans := GetDefaultPlans()
|
|
||||||
|
|
||||||
for _, plan := range plans {
|
|
||||||
if plan.Currency != "eur" {
|
|
||||||
t.Errorf("Plan %s currency should be 'eur', got '%s'", plan.ID, plan.Currency)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBillingPlan_IntervalIsMonth(t *testing.T) {
|
|
||||||
plans := GetDefaultPlans()
|
|
||||||
|
|
||||||
for _, plan := range plans {
|
|
||||||
if plan.Interval != "month" {
|
|
||||||
t.Errorf("Plan %s interval should be 'month', got '%s'", plan.ID, plan.Interval)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBillingPlan_SortOrder(t *testing.T) {
|
|
||||||
plans := GetDefaultPlans()
|
|
||||||
|
|
||||||
for i, plan := range plans {
|
|
||||||
expectedOrder := i + 1
|
|
||||||
if plan.SortOrder != expectedOrder {
|
|
||||||
t.Errorf("Plan %s sort order should be %d, got %d",
|
|
||||||
plan.ID, expectedOrder, plan.SortOrder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskUsageInfo_FormatStrings(t *testing.T) {
|
|
||||||
usage := TaskUsageInfo{
|
|
||||||
TasksAvailable: 45,
|
|
||||||
MaxTasks: 150,
|
|
||||||
InfoText: "Aufgaben verfuegbar: 45 von max. 150",
|
|
||||||
TooltipText: "Aufgaben koennen sich bis zu 5 Monate ansammeln.",
|
|
||||||
}
|
|
||||||
|
|
||||||
if usage.TasksAvailable != 45 {
|
|
||||||
t.Errorf("TasksAvailable should be 45, got %d", usage.TasksAvailable)
|
|
||||||
}
|
|
||||||
if usage.MaxTasks != 150 {
|
|
||||||
t.Errorf("MaxTasks should be 150, got %d", usage.MaxTasks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckTaskAllowedResponse_Allowed(t *testing.T) {
|
|
||||||
response := CheckTaskAllowedResponse{
|
|
||||||
Allowed: true,
|
|
||||||
TasksAvailable: 50,
|
|
||||||
MaxTasks: 150,
|
|
||||||
PlanID: PlanBasic,
|
|
||||||
}
|
|
||||||
|
|
||||||
if !response.Allowed {
|
|
||||||
t.Error("Response should be allowed")
|
|
||||||
}
|
|
||||||
if response.Message != "" {
|
|
||||||
t.Errorf("Message should be empty for allowed response, got '%s'", response.Message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckTaskAllowedResponse_NotAllowed(t *testing.T) {
|
|
||||||
response := CheckTaskAllowedResponse{
|
|
||||||
Allowed: false,
|
|
||||||
TasksAvailable: 0,
|
|
||||||
MaxTasks: 150,
|
|
||||||
PlanID: PlanBasic,
|
|
||||||
Message: "Dein Aufgaben-Kontingent ist aufgebraucht.",
|
|
||||||
}
|
|
||||||
|
|
||||||
if response.Allowed {
|
|
||||||
t.Error("Response should not be allowed")
|
|
||||||
}
|
|
||||||
if response.TasksAvailable != 0 {
|
|
||||||
t.Errorf("TasksAvailable should be 0, got %d", response.TasksAvailable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskLimitError(t *testing.T) {
|
|
||||||
err := TaskLimitError{
|
|
||||||
Error: "TASK_LIMIT_REACHED",
|
|
||||||
CurrentBalance: 0,
|
|
||||||
Plan: PlanBasic,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err.Error != "TASK_LIMIT_REACHED" {
|
|
||||||
t.Errorf("Error should be 'TASK_LIMIT_REACHED', got '%s'", err.Error)
|
|
||||||
}
|
|
||||||
if err.CurrentBalance != 0 {
|
|
||||||
t.Errorf("CurrentBalance should be 0, got %d", err.CurrentBalance)
|
|
||||||
}
|
|
||||||
if err.Plan != PlanBasic {
|
|
||||||
t.Errorf("Plan should be basic, got '%s'", err.Plan)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsumeTaskRequest(t *testing.T) {
|
|
||||||
req := ConsumeTaskRequest{
|
|
||||||
UserID: "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
TaskType: TaskTypeCorrection,
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.UserID == "" {
|
|
||||||
t.Error("UserID should not be empty")
|
|
||||||
}
|
|
||||||
if req.TaskType != TaskTypeCorrection {
|
|
||||||
t.Errorf("TaskType should be correction, got '%s'", req.TaskType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsumeTaskResponse_Success(t *testing.T) {
|
|
||||||
resp := ConsumeTaskResponse{
|
|
||||||
Success: true,
|
|
||||||
TaskID: "task-123",
|
|
||||||
TasksRemaining: 49,
|
|
||||||
}
|
|
||||||
|
|
||||||
if !resp.Success {
|
|
||||||
t.Error("Response should be successful")
|
|
||||||
}
|
|
||||||
if resp.TasksRemaining != 49 {
|
|
||||||
t.Errorf("TasksRemaining should be 49, got %d", resp.TasksRemaining)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEntitlementInfo_Premium(t *testing.T) {
|
|
||||||
premium := GetDefaultPlans()[2]
|
|
||||||
|
|
||||||
info := EntitlementInfo{
|
|
||||||
Features: premium.Features.FeatureFlags,
|
|
||||||
MaxTeamMembers: premium.Features.MaxTeamMembers,
|
|
||||||
PrioritySupport: premium.Features.PrioritySupport,
|
|
||||||
CustomBranding: premium.Features.CustomBranding,
|
|
||||||
BatchProcessing: premium.Features.BatchProcessing,
|
|
||||||
CustomTemplates: premium.Features.CustomTemplates,
|
|
||||||
FairUseMode: premium.Features.FairUseMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
if !info.FairUseMode {
|
|
||||||
t.Error("Premium should have FairUseMode")
|
|
||||||
}
|
|
||||||
if info.MaxTeamMembers != 10 {
|
|
||||||
t.Errorf("Premium MaxTeamMembers should be 10, got %d", info.MaxTeamMembers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/breakpilot/billing-service/internal/database"
|
|
||||||
"github.com/breakpilot/billing-service/internal/models"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EntitlementService handles entitlement-related operations
|
|
||||||
type EntitlementService struct {
|
|
||||||
db *database.DB
|
|
||||||
subService *SubscriptionService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewEntitlementService creates a new EntitlementService
|
|
||||||
func NewEntitlementService(db *database.DB, subService *SubscriptionService) *EntitlementService {
|
|
||||||
return &EntitlementService{
|
|
||||||
db: db,
|
|
||||||
subService: subService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEntitlements returns the entitlement info for a user
|
|
||||||
func (s *EntitlementService) GetEntitlements(ctx context.Context, userID uuid.UUID) (*models.EntitlementInfo, error) {
|
|
||||||
entitlements, err := s.getUserEntitlements(ctx, userID)
|
|
||||||
if err != nil || entitlements == nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &models.EntitlementInfo{
|
|
||||||
Features: entitlements.Features.FeatureFlags,
|
|
||||||
MaxTeamMembers: entitlements.Features.MaxTeamMembers,
|
|
||||||
PrioritySupport: entitlements.Features.PrioritySupport,
|
|
||||||
CustomBranding: entitlements.Features.CustomBranding,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEntitlementsByUserIDString returns entitlements by user ID string (for internal API)
|
|
||||||
func (s *EntitlementService) GetEntitlementsByUserIDString(ctx context.Context, userIDStr string) (*models.UserEntitlements, error) {
|
|
||||||
userID, err := uuid.Parse(userIDStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.getUserEntitlements(ctx, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getUserEntitlements retrieves or creates entitlements for a user
|
|
||||||
func (s *EntitlementService) getUserEntitlements(ctx context.Context, userID uuid.UUID) (*models.UserEntitlements, error) {
|
|
||||||
query := `
|
|
||||||
SELECT id, user_id, plan_id, ai_requests_limit, ai_requests_used,
|
|
||||||
documents_limit, documents_used, features, period_start, period_end,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM user_entitlements
|
|
||||||
WHERE user_id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
var ent models.UserEntitlements
|
|
||||||
var featuresJSON []byte
|
|
||||||
var periodStart, periodEnd *time.Time
|
|
||||||
|
|
||||||
err := s.db.Pool.QueryRow(ctx, query, userID).Scan(
|
|
||||||
&ent.ID, &ent.UserID, &ent.PlanID, &ent.AIRequestsLimit, &ent.AIRequestsUsed,
|
|
||||||
&ent.DocumentsLimit, &ent.DocumentsUsed, &featuresJSON, &periodStart, &periodEnd,
|
|
||||||
nil, &ent.UpdatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if err.Error() == "no rows in result set" {
|
|
||||||
// Try to create entitlements based on subscription
|
|
||||||
return s.createEntitlementsFromSubscription(ctx, userID)
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(featuresJSON) > 0 {
|
|
||||||
json.Unmarshal(featuresJSON, &ent.Features)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ent, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createEntitlementsFromSubscription creates entitlements based on user's subscription
|
|
||||||
func (s *EntitlementService) createEntitlementsFromSubscription(ctx context.Context, userID uuid.UUID) (*models.UserEntitlements, error) {
|
|
||||||
// Get user's subscription
|
|
||||||
sub, err := s.subService.GetByUserID(ctx, userID)
|
|
||||||
if err != nil || sub == nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get plan details
|
|
||||||
plan, err := s.subService.GetPlanByID(ctx, string(sub.PlanID))
|
|
||||||
if err != nil || plan == nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create entitlements
|
|
||||||
return s.CreateEntitlements(ctx, userID, sub.PlanID, plan.Features, sub.CurrentPeriodEnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateEntitlements creates entitlements for a user
|
|
||||||
func (s *EntitlementService) CreateEntitlements(ctx context.Context, userID uuid.UUID, planID models.PlanID, features models.PlanFeatures, periodEnd *time.Time) (*models.UserEntitlements, error) {
|
|
||||||
featuresJSON, _ := json.Marshal(features)
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
periodStart := now
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO user_entitlements (
|
|
||||||
user_id, plan_id, ai_requests_limit, ai_requests_used,
|
|
||||||
documents_limit, documents_used, features, period_start, period_end
|
|
||||||
) VALUES ($1, $2, $3, 0, $4, 0, $5, $6, $7)
|
|
||||||
ON CONFLICT (user_id) DO UPDATE SET
|
|
||||||
plan_id = EXCLUDED.plan_id,
|
|
||||||
ai_requests_limit = EXCLUDED.ai_requests_limit,
|
|
||||||
documents_limit = EXCLUDED.documents_limit,
|
|
||||||
features = EXCLUDED.features,
|
|
||||||
period_start = EXCLUDED.period_start,
|
|
||||||
period_end = EXCLUDED.period_end,
|
|
||||||
updated_at = NOW()
|
|
||||||
RETURNING id, user_id, plan_id, ai_requests_limit, ai_requests_used,
|
|
||||||
documents_limit, documents_used, updated_at
|
|
||||||
`
|
|
||||||
|
|
||||||
var ent models.UserEntitlements
|
|
||||||
err := s.db.Pool.QueryRow(ctx, query,
|
|
||||||
userID, planID, features.AIRequestsLimit, features.DocumentsLimit,
|
|
||||||
featuresJSON, periodStart, periodEnd,
|
|
||||||
).Scan(
|
|
||||||
&ent.ID, &ent.UserID, &ent.PlanID, &ent.AIRequestsLimit, &ent.AIRequestsUsed,
|
|
||||||
&ent.DocumentsLimit, &ent.DocumentsUsed, &ent.UpdatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ent.Features = features
|
|
||||||
return &ent, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateEntitlements updates entitlements for a user (e.g., on plan change)
|
|
||||||
func (s *EntitlementService) UpdateEntitlements(ctx context.Context, userID uuid.UUID, planID models.PlanID, features models.PlanFeatures) error {
|
|
||||||
featuresJSON, _ := json.Marshal(features)
|
|
||||||
|
|
||||||
query := `
|
|
||||||
UPDATE user_entitlements SET
|
|
||||||
plan_id = $2,
|
|
||||||
ai_requests_limit = $3,
|
|
||||||
documents_limit = $4,
|
|
||||||
features = $5,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE user_id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
_, err := s.db.Pool.Exec(ctx, query,
|
|
||||||
userID, planID, features.AIRequestsLimit, features.DocumentsLimit, featuresJSON,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetUsageCounters resets usage counters for a new period
|
|
||||||
func (s *EntitlementService) ResetUsageCounters(ctx context.Context, userID uuid.UUID, newPeriodStart, newPeriodEnd *time.Time) error {
|
|
||||||
query := `
|
|
||||||
UPDATE user_entitlements SET
|
|
||||||
ai_requests_used = 0,
|
|
||||||
documents_used = 0,
|
|
||||||
period_start = $2,
|
|
||||||
period_end = $3,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE user_id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
_, err := s.db.Pool.Exec(ctx, query, userID, newPeriodStart, newPeriodEnd)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckEntitlement checks if a user has a specific feature entitlement
|
|
||||||
func (s *EntitlementService) CheckEntitlement(ctx context.Context, userIDStr, feature string) (bool, models.PlanID, error) {
|
|
||||||
userID, err := uuid.Parse(userIDStr)
|
|
||||||
if err != nil {
|
|
||||||
return false, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
ent, err := s.getUserEntitlements(ctx, userID)
|
|
||||||
if err != nil || ent == nil {
|
|
||||||
return false, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if feature is in the feature flags
|
|
||||||
for _, f := range ent.Features.FeatureFlags {
|
|
||||||
if f == feature {
|
|
||||||
return true, ent.PlanID, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, ent.PlanID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IncrementUsage increments a usage counter
|
|
||||||
func (s *EntitlementService) IncrementUsage(ctx context.Context, userID uuid.UUID, usageType string, amount int) error {
|
|
||||||
var column string
|
|
||||||
switch usageType {
|
|
||||||
case "ai_request":
|
|
||||||
column = "ai_requests_used"
|
|
||||||
case "document_created":
|
|
||||||
column = "documents_used"
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
|
||||||
UPDATE user_entitlements SET
|
|
||||||
` + column + ` = ` + column + ` + $2,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE user_id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
_, err := s.db.Pool.Exec(ctx, query, userID, amount)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteEntitlements removes entitlements for a user (on subscription cancellation)
|
|
||||||
func (s *EntitlementService) DeleteEntitlements(ctx context.Context, userID uuid.UUID) error {
|
|
||||||
query := `DELETE FROM user_entitlements WHERE user_id = $1`
|
|
||||||
_, err := s.db.Pool.Exec(ctx, query, userID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/breakpilot/billing-service/internal/models"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stripe/stripe-go/v76"
|
|
||||||
"github.com/stripe/stripe-go/v76/billingportal/session"
|
|
||||||
checkoutsession "github.com/stripe/stripe-go/v76/checkout/session"
|
|
||||||
"github.com/stripe/stripe-go/v76/customer"
|
|
||||||
"github.com/stripe/stripe-go/v76/price"
|
|
||||||
"github.com/stripe/stripe-go/v76/product"
|
|
||||||
"github.com/stripe/stripe-go/v76/subscription"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StripeService handles Stripe API interactions
|
|
||||||
type StripeService struct {
|
|
||||||
secretKey string
|
|
||||||
webhookSecret string
|
|
||||||
successURL string
|
|
||||||
cancelURL string
|
|
||||||
trialPeriodDays int64
|
|
||||||
subService *SubscriptionService
|
|
||||||
mockMode bool // If true, don't make real Stripe API calls
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewStripeService creates a new StripeService
|
|
||||||
func NewStripeService(secretKey, webhookSecret, successURL, cancelURL string, trialPeriodDays int, subService *SubscriptionService) *StripeService {
|
|
||||||
// Initialize Stripe with the secret key (only if not empty)
|
|
||||||
if secretKey != "" {
|
|
||||||
stripe.Key = secretKey
|
|
||||||
}
|
|
||||||
|
|
||||||
return &StripeService{
|
|
||||||
secretKey: secretKey,
|
|
||||||
webhookSecret: webhookSecret,
|
|
||||||
successURL: successURL,
|
|
||||||
cancelURL: cancelURL,
|
|
||||||
trialPeriodDays: int64(trialPeriodDays),
|
|
||||||
subService: subService,
|
|
||||||
mockMode: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMockStripeService creates a mock StripeService for development
|
|
||||||
func NewMockStripeService(successURL, cancelURL string, trialPeriodDays int, subService *SubscriptionService) *StripeService {
|
|
||||||
return &StripeService{
|
|
||||||
secretKey: "",
|
|
||||||
webhookSecret: "",
|
|
||||||
successURL: successURL,
|
|
||||||
cancelURL: cancelURL,
|
|
||||||
trialPeriodDays: int64(trialPeriodDays),
|
|
||||||
subService: subService,
|
|
||||||
mockMode: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsMockMode returns true if running in mock mode
|
|
||||||
func (s *StripeService) IsMockMode() bool {
|
|
||||||
return s.mockMode
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateCheckoutSession creates a Stripe Checkout session for trial start
|
|
||||||
func (s *StripeService) CreateCheckoutSession(ctx context.Context, userID uuid.UUID, email string, planID models.PlanID) (string, string, error) {
|
|
||||||
// Mock mode: return a fake URL for development
|
|
||||||
if s.mockMode {
|
|
||||||
mockSessionID := fmt.Sprintf("mock_cs_%s", uuid.New().String()[:8])
|
|
||||||
mockURL := fmt.Sprintf("%s?session_id=%s&mock=true&plan=%s", s.successURL, mockSessionID, planID)
|
|
||||||
return mockURL, mockSessionID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get plan details
|
|
||||||
plan, err := s.subService.GetPlanByID(ctx, string(planID))
|
|
||||||
if err != nil || plan == nil {
|
|
||||||
return "", "", fmt.Errorf("plan not found: %s", planID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure we have a Stripe price ID
|
|
||||||
if plan.StripePriceID == "" {
|
|
||||||
// Create product and price in Stripe if not exists
|
|
||||||
priceID, err := s.ensurePriceExists(ctx, plan)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("failed to create stripe price: %w", err)
|
|
||||||
}
|
|
||||||
plan.StripePriceID = priceID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create checkout session parameters
|
|
||||||
params := &stripe.CheckoutSessionParams{
|
|
||||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
|
||||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
|
||||||
{
|
|
||||||
Price: stripe.String(plan.StripePriceID),
|
|
||||||
Quantity: stripe.Int64(1),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
SuccessURL: stripe.String(s.successURL + "?session_id={CHECKOUT_SESSION_ID}"),
|
|
||||||
CancelURL: stripe.String(s.cancelURL),
|
|
||||||
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
|
|
||||||
TrialPeriodDays: stripe.Int64(s.trialPeriodDays),
|
|
||||||
Metadata: map[string]string{
|
|
||||||
"user_id": userID.String(),
|
|
||||||
"plan_id": string(planID),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
PaymentMethodCollection: stripe.String(string(stripe.CheckoutSessionPaymentMethodCollectionAlways)),
|
|
||||||
Metadata: map[string]string{
|
|
||||||
"user_id": userID.String(),
|
|
||||||
"plan_id": string(planID),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set customer email if provided
|
|
||||||
if email != "" {
|
|
||||||
params.CustomerEmail = stripe.String(email)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the session
|
|
||||||
sess, err := checkoutsession.New(params)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("failed to create checkout session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sess.URL, sess.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensurePriceExists creates a Stripe product and price if they don't exist
|
|
||||||
func (s *StripeService) ensurePriceExists(ctx context.Context, plan *models.BillingPlan) (string, error) {
|
|
||||||
// Create product
|
|
||||||
productParams := &stripe.ProductParams{
|
|
||||||
Name: stripe.String(plan.Name),
|
|
||||||
Description: stripe.String(plan.Description),
|
|
||||||
Metadata: map[string]string{
|
|
||||||
"plan_id": string(plan.ID),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
prod, err := product.New(productParams)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create product: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create price
|
|
||||||
priceParams := &stripe.PriceParams{
|
|
||||||
Product: stripe.String(prod.ID),
|
|
||||||
UnitAmount: stripe.Int64(int64(plan.PriceCents)),
|
|
||||||
Currency: stripe.String(plan.Currency),
|
|
||||||
Recurring: &stripe.PriceRecurringParams{
|
|
||||||
Interval: stripe.String(plan.Interval),
|
|
||||||
},
|
|
||||||
Metadata: map[string]string{
|
|
||||||
"plan_id": string(plan.ID),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pr, err := price.New(priceParams)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create price: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update plan with Stripe IDs
|
|
||||||
if err := s.subService.UpdatePlanStripePriceID(ctx, string(plan.ID), pr.ID, prod.ID); err != nil {
|
|
||||||
// Log but don't fail
|
|
||||||
fmt.Printf("Warning: Failed to update plan with Stripe IDs: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return pr.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetOrCreateCustomer gets or creates a Stripe customer for a user
|
|
||||||
func (s *StripeService) GetOrCreateCustomer(ctx context.Context, email, name string, userID uuid.UUID) (string, error) {
|
|
||||||
// Search for existing customer
|
|
||||||
params := &stripe.CustomerSearchParams{
|
|
||||||
SearchParams: stripe.SearchParams{
|
|
||||||
Query: fmt.Sprintf("email:'%s'", email),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
iter := customer.Search(params)
|
|
||||||
for iter.Next() {
|
|
||||||
cust := iter.Customer()
|
|
||||||
// Check if this customer belongs to our user
|
|
||||||
if cust.Metadata["user_id"] == userID.String() {
|
|
||||||
return cust.ID, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new customer
|
|
||||||
customerParams := &stripe.CustomerParams{
|
|
||||||
Email: stripe.String(email),
|
|
||||||
Name: stripe.String(name),
|
|
||||||
Metadata: map[string]string{
|
|
||||||
"user_id": userID.String(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cust, err := customer.New(customerParams)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create customer: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cust.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChangePlan changes a subscription to a new plan
|
|
||||||
func (s *StripeService) ChangePlan(ctx context.Context, stripeSubID string, newPlanID models.PlanID) error {
|
|
||||||
// Mock mode: just return success
|
|
||||||
if s.mockMode {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get new plan details
|
|
||||||
plan, err := s.subService.GetPlanByID(ctx, string(newPlanID))
|
|
||||||
if err != nil || plan == nil {
|
|
||||||
return fmt.Errorf("plan not found: %s", newPlanID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if plan.StripePriceID == "" {
|
|
||||||
return fmt.Errorf("plan %s has no Stripe price ID", newPlanID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current subscription
|
|
||||||
sub, err := subscription.Get(stripeSubID, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get subscription: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update subscription with new price
|
|
||||||
params := &stripe.SubscriptionParams{
|
|
||||||
Items: []*stripe.SubscriptionItemsParams{
|
|
||||||
{
|
|
||||||
ID: stripe.String(sub.Items.Data[0].ID),
|
|
||||||
Price: stripe.String(plan.StripePriceID),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)),
|
|
||||||
Metadata: map[string]string{
|
|
||||||
"plan_id": string(newPlanID),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = subscription.Update(stripeSubID, params)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to update subscription: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CancelSubscription cancels a subscription at period end
|
|
||||||
func (s *StripeService) CancelSubscription(ctx context.Context, stripeSubID string) error {
|
|
||||||
// Mock mode: just return success
|
|
||||||
if s.mockMode {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
params := &stripe.SubscriptionParams{
|
|
||||||
CancelAtPeriodEnd: stripe.Bool(true),
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := subscription.Update(stripeSubID, params)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to cancel subscription: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReactivateSubscription removes the cancel_at_period_end flag
|
|
||||||
func (s *StripeService) ReactivateSubscription(ctx context.Context, stripeSubID string) error {
|
|
||||||
// Mock mode: just return success
|
|
||||||
if s.mockMode {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
params := &stripe.SubscriptionParams{
|
|
||||||
CancelAtPeriodEnd: stripe.Bool(false),
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := subscription.Update(stripeSubID, params)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to reactivate subscription: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateCustomerPortalSession creates a Stripe Customer Portal session
|
|
||||||
func (s *StripeService) CreateCustomerPortalSession(ctx context.Context, customerID string) (string, error) {
|
|
||||||
// Mock mode: return a mock URL
|
|
||||||
if s.mockMode {
|
|
||||||
return fmt.Sprintf("%s?mock_portal=true", s.successURL), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
params := &stripe.BillingPortalSessionParams{
|
|
||||||
Customer: stripe.String(customerID),
|
|
||||||
ReturnURL: stripe.String(s.successURL),
|
|
||||||
}
|
|
||||||
|
|
||||||
sess, err := session.New(params)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create portal session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sess.URL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSubscription retrieves a subscription from Stripe
|
|
||||||
func (s *StripeService) GetSubscription(ctx context.Context, stripeSubID string) (*stripe.Subscription, error) {
|
|
||||||
sub, err := subscription.Get(stripeSubID, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get subscription: %w", err)
|
|
||||||
}
|
|
||||||
return sub, nil
|
|
||||||
}
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/breakpilot/billing-service/internal/database"
|
|
||||||
"github.com/breakpilot/billing-service/internal/models"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SubscriptionService handles subscription-related operations
|
|
||||||
type SubscriptionService struct {
|
|
||||||
db *database.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSubscriptionService creates a new SubscriptionService
|
|
||||||
func NewSubscriptionService(db *database.DB) *SubscriptionService {
|
|
||||||
return &SubscriptionService{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByUserID retrieves a subscription by user ID
|
|
||||||
func (s *SubscriptionService) GetByUserID(ctx context.Context, userID uuid.UUID) (*models.Subscription, error) {
|
|
||||||
query := `
|
|
||||||
SELECT id, user_id, stripe_customer_id, stripe_subscription_id, plan_id,
|
|
||||||
status, trial_end, current_period_end, cancel_at_period_end,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM subscriptions
|
|
||||||
WHERE user_id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
var sub models.Subscription
|
|
||||||
var stripeCustomerID, stripeSubID *string
|
|
||||||
var trialEnd, periodEnd *time.Time
|
|
||||||
|
|
||||||
err := s.db.Pool.QueryRow(ctx, query, userID).Scan(
|
|
||||||
&sub.ID, &sub.UserID, &stripeCustomerID, &stripeSubID, &sub.PlanID,
|
|
||||||
&sub.Status, &trialEnd, &periodEnd, &sub.CancelAtPeriodEnd,
|
|
||||||
&sub.CreatedAt, &sub.UpdatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if err.Error() == "no rows in result set" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if stripeCustomerID != nil {
|
|
||||||
sub.StripeCustomerID = *stripeCustomerID
|
|
||||||
}
|
|
||||||
if stripeSubID != nil {
|
|
||||||
sub.StripeSubscriptionID = *stripeSubID
|
|
||||||
}
|
|
||||||
sub.TrialEnd = trialEnd
|
|
||||||
sub.CurrentPeriodEnd = periodEnd
|
|
||||||
|
|
||||||
return &sub, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByStripeSubscriptionID retrieves a subscription by Stripe subscription ID
|
|
||||||
func (s *SubscriptionService) GetByStripeSubscriptionID(ctx context.Context, stripeSubID string) (*models.Subscription, error) {
|
|
||||||
query := `
|
|
||||||
SELECT id, user_id, stripe_customer_id, stripe_subscription_id, plan_id,
|
|
||||||
status, trial_end, current_period_end, cancel_at_period_end,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM subscriptions
|
|
||||||
WHERE stripe_subscription_id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
var sub models.Subscription
|
|
||||||
var stripeCustomerID, subID *string
|
|
||||||
var trialEnd, periodEnd *time.Time
|
|
||||||
|
|
||||||
err := s.db.Pool.QueryRow(ctx, query, stripeSubID).Scan(
|
|
||||||
&sub.ID, &sub.UserID, &stripeCustomerID, &subID, &sub.PlanID,
|
|
||||||
&sub.Status, &trialEnd, &periodEnd, &sub.CancelAtPeriodEnd,
|
|
||||||
&sub.CreatedAt, &sub.UpdatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if err.Error() == "no rows in result set" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if stripeCustomerID != nil {
|
|
||||||
sub.StripeCustomerID = *stripeCustomerID
|
|
||||||
}
|
|
||||||
if subID != nil {
|
|
||||||
sub.StripeSubscriptionID = *subID
|
|
||||||
}
|
|
||||||
sub.TrialEnd = trialEnd
|
|
||||||
sub.CurrentPeriodEnd = periodEnd
|
|
||||||
|
|
||||||
return &sub, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create creates a new subscription
|
|
||||||
func (s *SubscriptionService) Create(ctx context.Context, sub *models.Subscription) error {
|
|
||||||
query := `
|
|
||||||
INSERT INTO subscriptions (
|
|
||||||
user_id, stripe_customer_id, stripe_subscription_id, plan_id,
|
|
||||||
status, trial_end, current_period_end, cancel_at_period_end
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
||||||
RETURNING id, created_at, updated_at
|
|
||||||
`
|
|
||||||
|
|
||||||
return s.db.Pool.QueryRow(ctx, query,
|
|
||||||
sub.UserID, sub.StripeCustomerID, sub.StripeSubscriptionID, sub.PlanID,
|
|
||||||
sub.Status, sub.TrialEnd, sub.CurrentPeriodEnd, sub.CancelAtPeriodEnd,
|
|
||||||
).Scan(&sub.ID, &sub.CreatedAt, &sub.UpdatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update updates an existing subscription
|
|
||||||
func (s *SubscriptionService) Update(ctx context.Context, sub *models.Subscription) error {
|
|
||||||
query := `
|
|
||||||
UPDATE subscriptions SET
|
|
||||||
stripe_customer_id = $2,
|
|
||||||
stripe_subscription_id = $3,
|
|
||||||
plan_id = $4,
|
|
||||||
status = $5,
|
|
||||||
trial_end = $6,
|
|
||||||
current_period_end = $7,
|
|
||||||
cancel_at_period_end = $8,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
_, err := s.db.Pool.Exec(ctx, query,
|
|
||||||
sub.ID, sub.StripeCustomerID, sub.StripeSubscriptionID, sub.PlanID,
|
|
||||||
sub.Status, sub.TrialEnd, sub.CurrentPeriodEnd, sub.CancelAtPeriodEnd,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateStatus updates the subscription status
|
|
||||||
func (s *SubscriptionService) UpdateStatus(ctx context.Context, id uuid.UUID, status models.SubscriptionStatus) error {
|
|
||||||
query := `UPDATE subscriptions SET status = $2, updated_at = NOW() WHERE id = $1`
|
|
||||||
_, err := s.db.Pool.Exec(ctx, query, id, status)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAvailablePlans retrieves all active billing plans
|
|
||||||
func (s *SubscriptionService) GetAvailablePlans(ctx context.Context) ([]models.BillingPlan, error) {
|
|
||||||
query := `
|
|
||||||
SELECT id, stripe_price_id, name, description, price_cents,
|
|
||||||
currency, interval, features, is_active, sort_order
|
|
||||||
FROM billing_plans
|
|
||||||
WHERE is_active = true
|
|
||||||
ORDER BY sort_order ASC
|
|
||||||
`
|
|
||||||
|
|
||||||
rows, err := s.db.Pool.Query(ctx, query)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var plans []models.BillingPlan
|
|
||||||
for rows.Next() {
|
|
||||||
var plan models.BillingPlan
|
|
||||||
var stripePriceID *string
|
|
||||||
var featuresJSON []byte
|
|
||||||
|
|
||||||
err := rows.Scan(
|
|
||||||
&plan.ID, &stripePriceID, &plan.Name, &plan.Description,
|
|
||||||
&plan.PriceCents, &plan.Currency, &plan.Interval,
|
|
||||||
&featuresJSON, &plan.IsActive, &plan.SortOrder,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if stripePriceID != nil {
|
|
||||||
plan.StripePriceID = *stripePriceID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse features JSON
|
|
||||||
if len(featuresJSON) > 0 {
|
|
||||||
json.Unmarshal(featuresJSON, &plan.Features)
|
|
||||||
}
|
|
||||||
|
|
||||||
plans = append(plans, plan)
|
|
||||||
}
|
|
||||||
|
|
||||||
return plans, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPlanByID retrieves a billing plan by ID
|
|
||||||
func (s *SubscriptionService) GetPlanByID(ctx context.Context, planID string) (*models.BillingPlan, error) {
|
|
||||||
query := `
|
|
||||||
SELECT id, stripe_price_id, name, description, price_cents,
|
|
||||||
currency, interval, features, is_active, sort_order
|
|
||||||
FROM billing_plans
|
|
||||||
WHERE id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
var plan models.BillingPlan
|
|
||||||
var stripePriceID *string
|
|
||||||
var featuresJSON []byte
|
|
||||||
|
|
||||||
err := s.db.Pool.QueryRow(ctx, query, planID).Scan(
|
|
||||||
&plan.ID, &stripePriceID, &plan.Name, &plan.Description,
|
|
||||||
&plan.PriceCents, &plan.Currency, &plan.Interval,
|
|
||||||
&featuresJSON, &plan.IsActive, &plan.SortOrder,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if err.Error() == "no rows in result set" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if stripePriceID != nil {
|
|
||||||
plan.StripePriceID = *stripePriceID
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(featuresJSON) > 0 {
|
|
||||||
json.Unmarshal(featuresJSON, &plan.Features)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &plan, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdatePlanStripePriceID updates the Stripe price ID for a plan
|
|
||||||
func (s *SubscriptionService) UpdatePlanStripePriceID(ctx context.Context, planID, stripePriceID, stripeProductID string) error {
|
|
||||||
query := `
|
|
||||||
UPDATE billing_plans
|
|
||||||
SET stripe_price_id = $2, stripe_product_id = $3, updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
`
|
|
||||||
_, err := s.db.Pool.Exec(ctx, query, planID, stripePriceID, stripeProductID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Webhook Event Tracking (Idempotency)
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// IsEventProcessed checks if a webhook event has already been processed
|
|
||||||
func (s *SubscriptionService) IsEventProcessed(ctx context.Context, eventID string) (bool, error) {
|
|
||||||
query := `SELECT processed FROM stripe_webhook_events WHERE stripe_event_id = $1`
|
|
||||||
|
|
||||||
var processed bool
|
|
||||||
err := s.db.Pool.QueryRow(ctx, query, eventID).Scan(&processed)
|
|
||||||
if err != nil {
|
|
||||||
if err.Error() == "no rows in result set" {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return processed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkEventProcessing marks an event as being processed
|
|
||||||
func (s *SubscriptionService) MarkEventProcessing(ctx context.Context, eventID, eventType string) error {
|
|
||||||
query := `
|
|
||||||
INSERT INTO stripe_webhook_events (stripe_event_id, event_type, processed)
|
|
||||||
VALUES ($1, $2, false)
|
|
||||||
ON CONFLICT (stripe_event_id) DO NOTHING
|
|
||||||
`
|
|
||||||
_, err := s.db.Pool.Exec(ctx, query, eventID, eventType)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkEventProcessed marks an event as successfully processed
|
|
||||||
func (s *SubscriptionService) MarkEventProcessed(ctx context.Context, eventID string) error {
|
|
||||||
query := `
|
|
||||||
UPDATE stripe_webhook_events
|
|
||||||
SET processed = true, processed_at = NOW()
|
|
||||||
WHERE stripe_event_id = $1
|
|
||||||
`
|
|
||||||
_, err := s.db.Pool.Exec(ctx, query, eventID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkEventFailed marks an event as failed with an error message
|
|
||||||
func (s *SubscriptionService) MarkEventFailed(ctx context.Context, eventID, errorMsg string) error {
|
|
||||||
query := `
|
|
||||||
UPDATE stripe_webhook_events
|
|
||||||
SET processed = false, error_message = $2, processed_at = NOW()
|
|
||||||
WHERE stripe_event_id = $1
|
|
||||||
`
|
|
||||||
_, err := s.db.Pool.Exec(ctx, query, eventID, errorMsg)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Audit Logging
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// LogAuditEvent logs a billing audit event
|
|
||||||
func (s *SubscriptionService) LogAuditEvent(ctx context.Context, userID *uuid.UUID, action, entityType, entityID string, oldValue, newValue, metadata interface{}, ipAddress, userAgent string) error {
|
|
||||||
oldJSON, _ := json.Marshal(oldValue)
|
|
||||||
newJSON, _ := json.Marshal(newValue)
|
|
||||||
metaJSON, _ := json.Marshal(metadata)
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO billing_audit_log (
|
|
||||||
user_id, action, entity_type, entity_id,
|
|
||||||
old_value, new_value, metadata, ip_address, user_agent
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
||||||
`
|
|
||||||
|
|
||||||
_, err := s.db.Pool.Exec(ctx, query,
|
|
||||||
userID, action, entityType, entityID,
|
|
||||||
oldJSON, newJSON, metaJSON, ipAddress, userAgent,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/breakpilot/billing-service/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSubscriptionStatus_Transitions(t *testing.T) {
|
|
||||||
// Test valid subscription status values
|
|
||||||
validStatuses := []models.SubscriptionStatus{
|
|
||||||
models.StatusTrialing,
|
|
||||||
models.StatusActive,
|
|
||||||
models.StatusPastDue,
|
|
||||||
models.StatusCanceled,
|
|
||||||
models.StatusExpired,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, status := range validStatuses {
|
|
||||||
if status == "" {
|
|
||||||
t.Errorf("Status should not be empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanID_ValidValues(t *testing.T) {
|
|
||||||
validPlanIDs := []models.PlanID{
|
|
||||||
models.PlanBasic,
|
|
||||||
models.PlanStandard,
|
|
||||||
models.PlanPremium,
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := []string{"basic", "standard", "premium"}
|
|
||||||
|
|
||||||
for i, planID := range validPlanIDs {
|
|
||||||
if string(planID) != expected[i] {
|
|
||||||
t.Errorf("PlanID should be '%s', got '%s'", expected[i], planID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanFeatures_JSONSerialization(t *testing.T) {
|
|
||||||
features := models.PlanFeatures{
|
|
||||||
MonthlyTaskAllowance: 100,
|
|
||||||
MaxTaskBalance: 500,
|
|
||||||
FeatureFlags: []string{"basic_ai", "templates"},
|
|
||||||
MaxTeamMembers: 3,
|
|
||||||
PrioritySupport: false,
|
|
||||||
CustomBranding: false,
|
|
||||||
BatchProcessing: true,
|
|
||||||
CustomTemplates: true,
|
|
||||||
FairUseMode: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test JSON serialization
|
|
||||||
data, err := json.Marshal(features)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal PlanFeatures: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test JSON deserialization
|
|
||||||
var decoded models.PlanFeatures
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to unmarshal PlanFeatures: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify fields
|
|
||||||
if decoded.MonthlyTaskAllowance != features.MonthlyTaskAllowance {
|
|
||||||
t.Errorf("MonthlyTaskAllowance mismatch: got %d, expected %d",
|
|
||||||
decoded.MonthlyTaskAllowance, features.MonthlyTaskAllowance)
|
|
||||||
}
|
|
||||||
if decoded.MaxTaskBalance != features.MaxTaskBalance {
|
|
||||||
t.Errorf("MaxTaskBalance mismatch: got %d, expected %d",
|
|
||||||
decoded.MaxTaskBalance, features.MaxTaskBalance)
|
|
||||||
}
|
|
||||||
if decoded.BatchProcessing != features.BatchProcessing {
|
|
||||||
t.Errorf("BatchProcessing mismatch: got %v, expected %v",
|
|
||||||
decoded.BatchProcessing, features.BatchProcessing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBillingPlan_DefaultPlansAreValid(t *testing.T) {
|
|
||||||
plans := models.GetDefaultPlans()
|
|
||||||
|
|
||||||
if len(plans) != 3 {
|
|
||||||
t.Fatalf("Expected 3 default plans, got %d", len(plans))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify all plans have required fields
|
|
||||||
for _, plan := range plans {
|
|
||||||
if plan.ID == "" {
|
|
||||||
t.Errorf("Plan ID should not be empty")
|
|
||||||
}
|
|
||||||
if plan.Name == "" {
|
|
||||||
t.Errorf("Plan '%s' should have a name", plan.ID)
|
|
||||||
}
|
|
||||||
if plan.Description == "" {
|
|
||||||
t.Errorf("Plan '%s' should have a description", plan.ID)
|
|
||||||
}
|
|
||||||
if plan.PriceCents <= 0 {
|
|
||||||
t.Errorf("Plan '%s' should have a positive price, got %d", plan.ID, plan.PriceCents)
|
|
||||||
}
|
|
||||||
if plan.Currency != "eur" {
|
|
||||||
t.Errorf("Plan '%s' currency should be 'eur', got '%s'", plan.ID, plan.Currency)
|
|
||||||
}
|
|
||||||
if plan.Interval != "month" {
|
|
||||||
t.Errorf("Plan '%s' interval should be 'month', got '%s'", plan.ID, plan.Interval)
|
|
||||||
}
|
|
||||||
if !plan.IsActive {
|
|
||||||
t.Errorf("Plan '%s' should be active", plan.ID)
|
|
||||||
}
|
|
||||||
if plan.SortOrder <= 0 {
|
|
||||||
t.Errorf("Plan '%s' should have a positive sort order, got %d", plan.ID, plan.SortOrder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBillingPlan_TaskAllowanceProgression(t *testing.T) {
|
|
||||||
plans := models.GetDefaultPlans()
|
|
||||||
|
|
||||||
// Basic should have lowest allowance
|
|
||||||
basic := plans[0]
|
|
||||||
standard := plans[1]
|
|
||||||
premium := plans[2]
|
|
||||||
|
|
||||||
if basic.Features.MonthlyTaskAllowance >= standard.Features.MonthlyTaskAllowance {
|
|
||||||
t.Error("Standard plan should have more tasks than Basic")
|
|
||||||
}
|
|
||||||
|
|
||||||
if standard.Features.MonthlyTaskAllowance >= premium.Features.MonthlyTaskAllowance {
|
|
||||||
t.Error("Premium plan should have more tasks than Standard")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBillingPlan_PriceProgression(t *testing.T) {
|
|
||||||
plans := models.GetDefaultPlans()
|
|
||||||
|
|
||||||
// Prices should increase with each tier
|
|
||||||
if plans[0].PriceCents >= plans[1].PriceCents {
|
|
||||||
t.Error("Standard should cost more than Basic")
|
|
||||||
}
|
|
||||||
if plans[1].PriceCents >= plans[2].PriceCents {
|
|
||||||
t.Error("Premium should cost more than Standard")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBillingPlan_FairUseModeOnlyForPremium(t *testing.T) {
|
|
||||||
plans := models.GetDefaultPlans()
|
|
||||||
|
|
||||||
for _, plan := range plans {
|
|
||||||
if plan.ID == models.PlanPremium {
|
|
||||||
if !plan.Features.FairUseMode {
|
|
||||||
t.Error("Premium plan should have FairUseMode enabled")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if plan.Features.FairUseMode {
|
|
||||||
t.Errorf("Plan '%s' should not have FairUseMode enabled", plan.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBillingPlan_MaxTaskBalanceCalculation(t *testing.T) {
|
|
||||||
plans := models.GetDefaultPlans()
|
|
||||||
|
|
||||||
for _, plan := range plans {
|
|
||||||
expected := plan.Features.MonthlyTaskAllowance * models.CarryoverMonthsCap
|
|
||||||
if plan.Features.MaxTaskBalance != expected {
|
|
||||||
t.Errorf("Plan '%s' MaxTaskBalance should be %d (allowance * 5), got %d",
|
|
||||||
plan.ID, expected, plan.Features.MaxTaskBalance)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuditLogJSON_Marshaling(t *testing.T) {
|
|
||||||
// Test that audit log values can be properly serialized
|
|
||||||
oldValue := map[string]interface{}{
|
|
||||||
"plan_id": "basic",
|
|
||||||
"status": "active",
|
|
||||||
}
|
|
||||||
|
|
||||||
newValue := map[string]interface{}{
|
|
||||||
"plan_id": "standard",
|
|
||||||
"status": "active",
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := map[string]interface{}{
|
|
||||||
"reason": "upgrade",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marshal all values
|
|
||||||
oldJSON, err := json.Marshal(oldValue)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal oldValue: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
newJSON, err := json.Marshal(newValue)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal newValue: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metaJSON, err := json.Marshal(metadata)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal metadata: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify non-empty
|
|
||||||
if len(oldJSON) == 0 || len(newJSON) == 0 || len(metaJSON) == 0 {
|
|
||||||
t.Error("JSON outputs should not be empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubscriptionTrialCalculation(t *testing.T) {
|
|
||||||
// Test trial days calculation logic
|
|
||||||
trialDays := 7
|
|
||||||
|
|
||||||
if trialDays <= 0 {
|
|
||||||
t.Error("Trial days should be positive")
|
|
||||||
}
|
|
||||||
|
|
||||||
if trialDays > 30 {
|
|
||||||
t.Error("Trial days should not exceed 30")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubscriptionInfo_TrialingStatus(t *testing.T) {
|
|
||||||
info := models.SubscriptionInfo{
|
|
||||||
PlanID: models.PlanBasic,
|
|
||||||
PlanName: "Basic",
|
|
||||||
Status: models.StatusTrialing,
|
|
||||||
IsTrialing: true,
|
|
||||||
TrialDaysLeft: 5,
|
|
||||||
CancelAtPeriodEnd: false,
|
|
||||||
PriceCents: 990,
|
|
||||||
Currency: "eur",
|
|
||||||
}
|
|
||||||
|
|
||||||
if !info.IsTrialing {
|
|
||||||
t.Error("Should be trialing")
|
|
||||||
}
|
|
||||||
if info.Status != models.StatusTrialing {
|
|
||||||
t.Errorf("Status should be 'trialing', got '%s'", info.Status)
|
|
||||||
}
|
|
||||||
if info.TrialDaysLeft <= 0 {
|
|
||||||
t.Error("TrialDaysLeft should be positive during trial")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubscriptionInfo_ActiveStatus(t *testing.T) {
|
|
||||||
info := models.SubscriptionInfo{
|
|
||||||
PlanID: models.PlanStandard,
|
|
||||||
PlanName: "Standard",
|
|
||||||
Status: models.StatusActive,
|
|
||||||
IsTrialing: false,
|
|
||||||
TrialDaysLeft: 0,
|
|
||||||
CancelAtPeriodEnd: false,
|
|
||||||
PriceCents: 1990,
|
|
||||||
Currency: "eur",
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.IsTrialing {
|
|
||||||
t.Error("Should not be trialing")
|
|
||||||
}
|
|
||||||
if info.Status != models.StatusActive {
|
|
||||||
t.Errorf("Status should be 'active', got '%s'", info.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubscriptionInfo_CanceledStatus(t *testing.T) {
|
|
||||||
info := models.SubscriptionInfo{
|
|
||||||
PlanID: models.PlanStandard,
|
|
||||||
PlanName: "Standard",
|
|
||||||
Status: models.StatusActive,
|
|
||||||
IsTrialing: false,
|
|
||||||
CancelAtPeriodEnd: true, // Scheduled for cancellation
|
|
||||||
PriceCents: 1990,
|
|
||||||
Currency: "eur",
|
|
||||||
}
|
|
||||||
|
|
||||||
if !info.CancelAtPeriodEnd {
|
|
||||||
t.Error("CancelAtPeriodEnd should be true")
|
|
||||||
}
|
|
||||||
// Status remains active until period end
|
|
||||||
if info.Status != models.StatusActive {
|
|
||||||
t.Errorf("Status should still be 'active', got '%s'", info.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhookEventTypes(t *testing.T) {
|
|
||||||
// Test common Stripe webhook event types we handle
|
|
||||||
eventTypes := []string{
|
|
||||||
"checkout.session.completed",
|
|
||||||
"customer.subscription.created",
|
|
||||||
"customer.subscription.updated",
|
|
||||||
"customer.subscription.deleted",
|
|
||||||
"invoice.paid",
|
|
||||||
"invoice.payment_failed",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, eventType := range eventTypes {
|
|
||||||
if eventType == "" {
|
|
||||||
t.Error("Event type should not be empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIdempotencyKey_Format(t *testing.T) {
|
|
||||||
// Test that we can handle Stripe event IDs
|
|
||||||
sampleEventIDs := []string{
|
|
||||||
"evt_1234567890abcdef",
|
|
||||||
"evt_test_abc123xyz789",
|
|
||||||
"evt_live_real_event_id",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, eventID := range sampleEventIDs {
|
|
||||||
if len(eventID) < 10 {
|
|
||||||
t.Errorf("Event ID '%s' seems too short", eventID)
|
|
||||||
}
|
|
||||||
// Stripe event IDs typically start with "evt_"
|
|
||||||
if eventID[:4] != "evt_" {
|
|
||||||
t.Errorf("Event ID '%s' should start with 'evt_'", eventID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/breakpilot/billing-service/internal/database"
|
|
||||||
"github.com/breakpilot/billing-service/internal/models"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// ErrTaskLimitReached is returned when task balance is 0
|
|
||||||
ErrTaskLimitReached = errors.New("TASK_LIMIT_REACHED")
|
|
||||||
// ErrNoSubscription is returned when user has no subscription
|
|
||||||
ErrNoSubscription = errors.New("NO_SUBSCRIPTION")
|
|
||||||
)
|
|
||||||
|
|
||||||
// TaskService handles task consumption and balance management
|
|
||||||
type TaskService struct {
|
|
||||||
db *database.DB
|
|
||||||
subService *SubscriptionService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTaskService creates a new TaskService
|
|
||||||
func NewTaskService(db *database.DB, subService *SubscriptionService) *TaskService {
|
|
||||||
return &TaskService{
|
|
||||||
db: db,
|
|
||||||
subService: subService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAccountUsage retrieves or creates account usage for a user
|
|
||||||
func (s *TaskService) GetAccountUsage(ctx context.Context, userID uuid.UUID) (*models.AccountUsage, error) {
|
|
||||||
query := `
|
|
||||||
SELECT id, account_id, plan, monthly_task_allowance, carryover_months_cap,
|
|
||||||
max_task_balance, task_balance, last_renewal_at, created_at, updated_at
|
|
||||||
FROM account_usage
|
|
||||||
WHERE account_id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
var usage models.AccountUsage
|
|
||||||
err := s.db.Pool.QueryRow(ctx, query, userID).Scan(
|
|
||||||
&usage.ID, &usage.AccountID, &usage.PlanID, &usage.MonthlyTaskAllowance,
|
|
||||||
&usage.CarryoverMonthsCap, &usage.MaxTaskBalance, &usage.TaskBalance,
|
|
||||||
&usage.LastRenewalAt, &usage.CreatedAt, &usage.UpdatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if err.Error() == "no rows in result set" {
|
|
||||||
// Create new account usage based on subscription
|
|
||||||
return s.createAccountUsage(ctx, userID)
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if month renewal is needed
|
|
||||||
if err := s.checkAndApplyMonthRenewal(ctx, &usage); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &usage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createAccountUsage creates account usage based on user's subscription
|
|
||||||
func (s *TaskService) createAccountUsage(ctx context.Context, userID uuid.UUID) (*models.AccountUsage, error) {
|
|
||||||
// Get subscription to determine plan
|
|
||||||
sub, err := s.subService.GetByUserID(ctx, userID)
|
|
||||||
if err != nil || sub == nil {
|
|
||||||
return nil, ErrNoSubscription
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get plan features
|
|
||||||
plan, err := s.subService.GetPlanByID(ctx, string(sub.PlanID))
|
|
||||||
if err != nil || plan == nil {
|
|
||||||
return nil, fmt.Errorf("plan not found: %s", sub.PlanID)
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
usage := &models.AccountUsage{
|
|
||||||
AccountID: userID,
|
|
||||||
PlanID: sub.PlanID,
|
|
||||||
MonthlyTaskAllowance: plan.Features.MonthlyTaskAllowance,
|
|
||||||
CarryoverMonthsCap: models.CarryoverMonthsCap,
|
|
||||||
MaxTaskBalance: plan.Features.MaxTaskBalance,
|
|
||||||
TaskBalance: plan.Features.MonthlyTaskAllowance, // Start with one month's worth
|
|
||||||
LastRenewalAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO account_usage (
|
|
||||||
account_id, plan, monthly_task_allowance, carryover_months_cap,
|
|
||||||
max_task_balance, task_balance, last_renewal_at
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING id, created_at, updated_at
|
|
||||||
`
|
|
||||||
|
|
||||||
err = s.db.Pool.QueryRow(ctx, query,
|
|
||||||
usage.AccountID, usage.PlanID, usage.MonthlyTaskAllowance,
|
|
||||||
usage.CarryoverMonthsCap, usage.MaxTaskBalance, usage.TaskBalance, usage.LastRenewalAt,
|
|
||||||
).Scan(&usage.ID, &usage.CreatedAt, &usage.UpdatedAt)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return usage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkAndApplyMonthRenewal checks if a month has passed and adds allowance
|
|
||||||
// Implements the carryover logic: tasks accumulate up to max_task_balance
|
|
||||||
func (s *TaskService) checkAndApplyMonthRenewal(ctx context.Context, usage *models.AccountUsage) error {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// Check if at least one month has passed since last renewal
|
|
||||||
monthsSinceRenewal := monthsBetween(usage.LastRenewalAt, now)
|
|
||||||
if monthsSinceRenewal < 1 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate new balance with carryover
|
|
||||||
// Add monthly allowance for each month that passed
|
|
||||||
newBalance := usage.TaskBalance
|
|
||||||
for i := 0; i < monthsSinceRenewal; i++ {
|
|
||||||
newBalance += usage.MonthlyTaskAllowance
|
|
||||||
// Cap at max balance
|
|
||||||
if newBalance > usage.MaxTaskBalance {
|
|
||||||
newBalance = usage.MaxTaskBalance
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate new renewal date (add the number of months)
|
|
||||||
newRenewalAt := usage.LastRenewalAt.AddDate(0, monthsSinceRenewal, 0)
|
|
||||||
|
|
||||||
// Update in database
|
|
||||||
query := `
|
|
||||||
UPDATE account_usage
|
|
||||||
SET task_balance = $2, last_renewal_at = $3, updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
`
|
|
||||||
_, err := s.db.Pool.Exec(ctx, query, usage.ID, newBalance, newRenewalAt)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update local struct
|
|
||||||
usage.TaskBalance = newBalance
|
|
||||||
usage.LastRenewalAt = newRenewalAt
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// monthsBetween calculates full months between two dates
|
|
||||||
func monthsBetween(start, end time.Time) int {
|
|
||||||
months := 0
|
|
||||||
for start.AddDate(0, months+1, 0).Before(end) || start.AddDate(0, months+1, 0).Equal(end) {
|
|
||||||
months++
|
|
||||||
}
|
|
||||||
return months
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckTaskAllowed checks if a task can be consumed (balance > 0)
|
|
||||||
func (s *TaskService) CheckTaskAllowed(ctx context.Context, userID uuid.UUID) (*models.CheckTaskAllowedResponse, error) {
|
|
||||||
usage, err := s.GetAccountUsage(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, ErrNoSubscription) {
|
|
||||||
return &models.CheckTaskAllowedResponse{
|
|
||||||
Allowed: false,
|
|
||||||
PlanID: "",
|
|
||||||
Message: "Kein aktives Abonnement gefunden.",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Premium Fair Use mode - always allow
|
|
||||||
plan, _ := s.subService.GetPlanByID(ctx, string(usage.PlanID))
|
|
||||||
if plan != nil && plan.Features.FairUseMode {
|
|
||||||
return &models.CheckTaskAllowedResponse{
|
|
||||||
Allowed: true,
|
|
||||||
TasksAvailable: usage.TaskBalance,
|
|
||||||
MaxTasks: usage.MaxTaskBalance,
|
|
||||||
PlanID: usage.PlanID,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
allowed := usage.TaskBalance > 0
|
|
||||||
|
|
||||||
response := &models.CheckTaskAllowedResponse{
|
|
||||||
Allowed: allowed,
|
|
||||||
TasksAvailable: usage.TaskBalance,
|
|
||||||
MaxTasks: usage.MaxTaskBalance,
|
|
||||||
PlanID: usage.PlanID,
|
|
||||||
}
|
|
||||||
|
|
||||||
if !allowed {
|
|
||||||
response.Message = "Dein Aufgaben-Kontingent ist aufgebraucht."
|
|
||||||
}
|
|
||||||
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConsumeTask consumes one task from the balance
|
|
||||||
// Returns error if balance is 0
|
|
||||||
func (s *TaskService) ConsumeTask(ctx context.Context, userID uuid.UUID, taskType models.TaskType) (*models.ConsumeTaskResponse, error) {
|
|
||||||
// First check if allowed
|
|
||||||
checkResponse, err := s.CheckTaskAllowed(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !checkResponse.Allowed {
|
|
||||||
return &models.ConsumeTaskResponse{
|
|
||||||
Success: false,
|
|
||||||
TasksRemaining: 0,
|
|
||||||
Message: checkResponse.Message,
|
|
||||||
}, ErrTaskLimitReached
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current usage
|
|
||||||
usage, err := s.GetAccountUsage(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start transaction
|
|
||||||
tx, err := s.db.Pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
// Decrement balance (only if not Premium Fair Use)
|
|
||||||
plan, _ := s.subService.GetPlanByID(ctx, string(usage.PlanID))
|
|
||||||
newBalance := usage.TaskBalance
|
|
||||||
if plan == nil || !plan.Features.FairUseMode {
|
|
||||||
newBalance = usage.TaskBalance - 1
|
|
||||||
_, err = tx.Exec(ctx, `
|
|
||||||
UPDATE account_usage
|
|
||||||
SET task_balance = $2, updated_at = NOW()
|
|
||||||
WHERE account_id = $1
|
|
||||||
`, userID, newBalance)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create task record
|
|
||||||
taskID := uuid.New()
|
|
||||||
_, err = tx.Exec(ctx, `
|
|
||||||
INSERT INTO tasks (id, account_id, task_type, consumed, created_at)
|
|
||||||
VALUES ($1, $2, $3, true, NOW())
|
|
||||||
`, taskID, userID, taskType)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commit transaction
|
|
||||||
if err = tx.Commit(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &models.ConsumeTaskResponse{
|
|
||||||
Success: true,
|
|
||||||
TaskID: taskID.String(),
|
|
||||||
TasksRemaining: newBalance,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTaskUsageInfo returns formatted task usage info for display
|
|
||||||
func (s *TaskService) GetTaskUsageInfo(ctx context.Context, userID uuid.UUID) (*models.TaskUsageInfo, error) {
|
|
||||||
usage, err := s.GetAccountUsage(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for Fair Use mode (Premium)
|
|
||||||
plan, _ := s.subService.GetPlanByID(ctx, string(usage.PlanID))
|
|
||||||
if plan != nil && plan.Features.FairUseMode {
|
|
||||||
return &models.TaskUsageInfo{
|
|
||||||
TasksAvailable: usage.TaskBalance,
|
|
||||||
MaxTasks: usage.MaxTaskBalance,
|
|
||||||
InfoText: "Unbegrenzte Aufgaben (Fair Use)",
|
|
||||||
TooltipText: "Im Premium-Tarif gibt es keine praktische Begrenzung.",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &models.TaskUsageInfo{
|
|
||||||
TasksAvailable: usage.TaskBalance,
|
|
||||||
MaxTasks: usage.MaxTaskBalance,
|
|
||||||
InfoText: fmt.Sprintf("Aufgaben verfuegbar: %d von max. %d", usage.TaskBalance, usage.MaxTaskBalance),
|
|
||||||
TooltipText: "Aufgaben koennen sich bis zu 5 Monate ansammeln.",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdatePlanForUser updates the plan and adjusts allowances
|
|
||||||
func (s *TaskService) UpdatePlanForUser(ctx context.Context, userID uuid.UUID, newPlanID models.PlanID) error {
|
|
||||||
plan, err := s.subService.GetPlanByID(ctx, string(newPlanID))
|
|
||||||
if err != nil || plan == nil {
|
|
||||||
return fmt.Errorf("plan not found: %s", newPlanID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update account usage with new plan limits
|
|
||||||
query := `
|
|
||||||
UPDATE account_usage
|
|
||||||
SET plan = $2,
|
|
||||||
monthly_task_allowance = $3,
|
|
||||||
max_task_balance = $4,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE account_id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
_, err = s.db.Pool.Exec(ctx, query,
|
|
||||||
userID, newPlanID, plan.Features.MonthlyTaskAllowance, plan.Features.MaxTaskBalance)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTaskHistory returns task history for a user
|
|
||||||
func (s *TaskService) GetTaskHistory(ctx context.Context, userID uuid.UUID, limit int) ([]models.Task, error) {
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
|
||||||
SELECT id, account_id, task_type, created_at, consumed
|
|
||||||
FROM tasks
|
|
||||||
WHERE account_id = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT $2
|
|
||||||
`
|
|
||||||
|
|
||||||
rows, err := s.db.Pool.Query(ctx, query, userID, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var tasks []models.Task
|
|
||||||
for rows.Next() {
|
|
||||||
var task models.Task
|
|
||||||
err := rows.Scan(&task.ID, &task.AccountID, &task.TaskType, &task.CreatedAt, &task.Consumed)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
tasks = append(tasks, task)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tasks, nil
|
|
||||||
}
|
|
||||||
@@ -1,397 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMonthsBetween(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
start time.Time
|
|
||||||
end time.Time
|
|
||||||
expected int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Same day",
|
|
||||||
start: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC),
|
|
||||||
end: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC),
|
|
||||||
expected: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Less than one month",
|
|
||||||
start: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC),
|
|
||||||
end: time.Date(2025, 2, 10, 0, 0, 0, 0, time.UTC),
|
|
||||||
expected: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Exactly one month",
|
|
||||||
start: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC),
|
|
||||||
end: time.Date(2025, 2, 15, 0, 0, 0, 0, time.UTC),
|
|
||||||
expected: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "One month and one day",
|
|
||||||
start: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC),
|
|
||||||
end: time.Date(2025, 2, 16, 0, 0, 0, 0, time.UTC),
|
|
||||||
expected: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Two months",
|
|
||||||
start: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC),
|
|
||||||
end: time.Date(2025, 3, 15, 0, 0, 0, 0, time.UTC),
|
|
||||||
expected: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Five months exactly",
|
|
||||||
start: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
||||||
end: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
|
||||||
expected: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Year boundary",
|
|
||||||
start: time.Date(2024, 11, 15, 0, 0, 0, 0, time.UTC),
|
|
||||||
end: time.Date(2025, 2, 15, 0, 0, 0, 0, time.UTC),
|
|
||||||
expected: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Leap year February to March",
|
|
||||||
start: time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC),
|
|
||||||
end: time.Date(2024, 3, 29, 0, 0, 0, 0, time.UTC),
|
|
||||||
expected: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := monthsBetween(tt.start, tt.end)
|
|
||||||
if result != tt.expected {
|
|
||||||
t.Errorf("monthsBetween(%v, %v) = %d, expected %d",
|
|
||||||
tt.start.Format("2006-01-02"), tt.end.Format("2006-01-02"),
|
|
||||||
result, tt.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCarryoverLogic(t *testing.T) {
|
|
||||||
// Test the carryover calculation logic
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
currentBalance int
|
|
||||||
monthlyAllowance int
|
|
||||||
maxBalance int
|
|
||||||
monthsSinceRenewal int
|
|
||||||
expectedNewBalance int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Normal renewal - add allowance",
|
|
||||||
currentBalance: 50,
|
|
||||||
monthlyAllowance: 30,
|
|
||||||
maxBalance: 150,
|
|
||||||
monthsSinceRenewal: 1,
|
|
||||||
expectedNewBalance: 80,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Two months missed",
|
|
||||||
currentBalance: 50,
|
|
||||||
monthlyAllowance: 30,
|
|
||||||
maxBalance: 150,
|
|
||||||
monthsSinceRenewal: 2,
|
|
||||||
expectedNewBalance: 110,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Cap at max balance",
|
|
||||||
currentBalance: 140,
|
|
||||||
monthlyAllowance: 30,
|
|
||||||
maxBalance: 150,
|
|
||||||
monthsSinceRenewal: 1,
|
|
||||||
expectedNewBalance: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Already at max - no change",
|
|
||||||
currentBalance: 150,
|
|
||||||
monthlyAllowance: 30,
|
|
||||||
maxBalance: 150,
|
|
||||||
monthsSinceRenewal: 1,
|
|
||||||
expectedNewBalance: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Multiple months - cap applies",
|
|
||||||
currentBalance: 100,
|
|
||||||
monthlyAllowance: 30,
|
|
||||||
maxBalance: 150,
|
|
||||||
monthsSinceRenewal: 5,
|
|
||||||
expectedNewBalance: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Empty balance - add one month",
|
|
||||||
currentBalance: 0,
|
|
||||||
monthlyAllowance: 30,
|
|
||||||
maxBalance: 150,
|
|
||||||
monthsSinceRenewal: 1,
|
|
||||||
expectedNewBalance: 30,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Empty balance - add five months",
|
|
||||||
currentBalance: 0,
|
|
||||||
monthlyAllowance: 30,
|
|
||||||
maxBalance: 150,
|
|
||||||
monthsSinceRenewal: 5,
|
|
||||||
expectedNewBalance: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Standard plan - normal case",
|
|
||||||
currentBalance: 200,
|
|
||||||
monthlyAllowance: 100,
|
|
||||||
maxBalance: 500,
|
|
||||||
monthsSinceRenewal: 1,
|
|
||||||
expectedNewBalance: 300,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Premium plan - Fair Use",
|
|
||||||
currentBalance: 1000,
|
|
||||||
monthlyAllowance: 1000,
|
|
||||||
maxBalance: 5000,
|
|
||||||
monthsSinceRenewal: 1,
|
|
||||||
expectedNewBalance: 2000,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Simulate the carryover logic
|
|
||||||
newBalance := tt.currentBalance
|
|
||||||
for i := 0; i < tt.monthsSinceRenewal; i++ {
|
|
||||||
newBalance += tt.monthlyAllowance
|
|
||||||
if newBalance > tt.maxBalance {
|
|
||||||
newBalance = tt.maxBalance
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if newBalance != tt.expectedNewBalance {
|
|
||||||
t.Errorf("Carryover for balance=%d, allowance=%d, max=%d, months=%d = %d, expected %d",
|
|
||||||
tt.currentBalance, tt.monthlyAllowance, tt.maxBalance, tt.monthsSinceRenewal,
|
|
||||||
newBalance, tt.expectedNewBalance)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskBalanceAfterConsumption(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
currentBalance int
|
|
||||||
tasksToConsume int
|
|
||||||
expectedBalance int
|
|
||||||
shouldBeAllowed bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Normal consumption",
|
|
||||||
currentBalance: 50,
|
|
||||||
tasksToConsume: 1,
|
|
||||||
expectedBalance: 49,
|
|
||||||
shouldBeAllowed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Last task",
|
|
||||||
currentBalance: 1,
|
|
||||||
tasksToConsume: 1,
|
|
||||||
expectedBalance: 0,
|
|
||||||
shouldBeAllowed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Empty balance - not allowed",
|
|
||||||
currentBalance: 0,
|
|
||||||
tasksToConsume: 1,
|
|
||||||
expectedBalance: 0,
|
|
||||||
shouldBeAllowed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Multiple tasks",
|
|
||||||
currentBalance: 50,
|
|
||||||
tasksToConsume: 5,
|
|
||||||
expectedBalance: 45,
|
|
||||||
shouldBeAllowed: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Test if allowed
|
|
||||||
allowed := tt.currentBalance > 0
|
|
||||||
if allowed != tt.shouldBeAllowed {
|
|
||||||
t.Errorf("Task allowed with balance=%d: got %v, expected %v",
|
|
||||||
tt.currentBalance, allowed, tt.shouldBeAllowed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test balance calculation
|
|
||||||
if allowed {
|
|
||||||
newBalance := tt.currentBalance - tt.tasksToConsume
|
|
||||||
if newBalance != tt.expectedBalance {
|
|
||||||
t.Errorf("Balance after consuming %d tasks from %d: got %d, expected %d",
|
|
||||||
tt.tasksToConsume, tt.currentBalance, newBalance, tt.expectedBalance)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskServiceErrors(t *testing.T) {
|
|
||||||
// Test error constants
|
|
||||||
if ErrTaskLimitReached == nil {
|
|
||||||
t.Error("ErrTaskLimitReached should not be nil")
|
|
||||||
}
|
|
||||||
if ErrTaskLimitReached.Error() != "TASK_LIMIT_REACHED" {
|
|
||||||
t.Errorf("ErrTaskLimitReached should be 'TASK_LIMIT_REACHED', got '%s'", ErrTaskLimitReached.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
if ErrNoSubscription == nil {
|
|
||||||
t.Error("ErrNoSubscription should not be nil")
|
|
||||||
}
|
|
||||||
if ErrNoSubscription.Error() != "NO_SUBSCRIPTION" {
|
|
||||||
t.Errorf("ErrNoSubscription should be 'NO_SUBSCRIPTION', got '%s'", ErrNoSubscription.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRenewalDateCalculation(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
lastRenewal time.Time
|
|
||||||
monthsToAdd int
|
|
||||||
expectedRenewal time.Time
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Add one month",
|
|
||||||
lastRenewal: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC),
|
|
||||||
monthsToAdd: 1,
|
|
||||||
expectedRenewal: time.Date(2025, 2, 15, 0, 0, 0, 0, time.UTC),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Add three months",
|
|
||||||
lastRenewal: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC),
|
|
||||||
monthsToAdd: 3,
|
|
||||||
expectedRenewal: time.Date(2025, 4, 15, 0, 0, 0, 0, time.UTC),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Year boundary",
|
|
||||||
lastRenewal: time.Date(2024, 11, 15, 0, 0, 0, 0, time.UTC),
|
|
||||||
monthsToAdd: 3,
|
|
||||||
expectedRenewal: time.Date(2025, 2, 15, 0, 0, 0, 0, time.UTC),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "End of month adjustment",
|
|
||||||
lastRenewal: time.Date(2025, 1, 31, 0, 0, 0, 0, time.UTC),
|
|
||||||
monthsToAdd: 1,
|
|
||||||
// Go's AddDate handles this - February doesn't have 31 days
|
|
||||||
expectedRenewal: time.Date(2025, 3, 3, 0, 0, 0, 0, time.UTC), // Feb 31 -> March 3
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := tt.lastRenewal.AddDate(0, tt.monthsToAdd, 0)
|
|
||||||
if !result.Equal(tt.expectedRenewal) {
|
|
||||||
t.Errorf("AddDate(%v, %d months) = %v, expected %v",
|
|
||||||
tt.lastRenewal.Format("2006-01-02"), tt.monthsToAdd,
|
|
||||||
result.Format("2006-01-02"), tt.expectedRenewal.Format("2006-01-02"))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFairUseModeLogic(t *testing.T) {
|
|
||||||
// Test that Fair Use mode always allows tasks regardless of balance
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
fairUseMode bool
|
|
||||||
balance int
|
|
||||||
shouldAllow bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Fair Use - zero balance still allowed",
|
|
||||||
fairUseMode: true,
|
|
||||||
balance: 0,
|
|
||||||
shouldAllow: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Fair Use - normal balance allowed",
|
|
||||||
fairUseMode: true,
|
|
||||||
balance: 1000,
|
|
||||||
shouldAllow: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Not Fair Use - zero balance not allowed",
|
|
||||||
fairUseMode: false,
|
|
||||||
balance: 0,
|
|
||||||
shouldAllow: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Not Fair Use - positive balance allowed",
|
|
||||||
fairUseMode: false,
|
|
||||||
balance: 50,
|
|
||||||
shouldAllow: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Simulate the check logic
|
|
||||||
var allowed bool
|
|
||||||
if tt.fairUseMode {
|
|
||||||
allowed = true // Fair Use always allows
|
|
||||||
} else {
|
|
||||||
allowed = tt.balance > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if allowed != tt.shouldAllow {
|
|
||||||
t.Errorf("FairUseMode=%v, balance=%d: allowed=%v, expected=%v",
|
|
||||||
tt.fairUseMode, tt.balance, allowed, tt.shouldAllow)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBalanceDecrementLogic(t *testing.T) {
|
|
||||||
// Test that Fair Use mode doesn't decrement balance
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
fairUseMode bool
|
|
||||||
initialBalance int
|
|
||||||
expectedAfter int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Normal plan - decrement",
|
|
||||||
fairUseMode: false,
|
|
||||||
initialBalance: 50,
|
|
||||||
expectedAfter: 49,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Fair Use - no decrement",
|
|
||||||
fairUseMode: true,
|
|
||||||
initialBalance: 1000,
|
|
||||||
expectedAfter: 1000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Normal plan - last task",
|
|
||||||
fairUseMode: false,
|
|
||||||
initialBalance: 1,
|
|
||||||
expectedAfter: 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
newBalance := tt.initialBalance
|
|
||||||
if !tt.fairUseMode {
|
|
||||||
newBalance = tt.initialBalance - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if newBalance != tt.expectedAfter {
|
|
||||||
t.Errorf("FairUseMode=%v, initial=%d: got %d, expected %d",
|
|
||||||
tt.fairUseMode, tt.initialBalance, newBalance, tt.expectedAfter)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/breakpilot/billing-service/internal/database"
|
|
||||||
"github.com/breakpilot/billing-service/internal/models"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// UsageService handles usage tracking operations
|
|
||||||
type UsageService struct {
|
|
||||||
db *database.DB
|
|
||||||
entitlementService *EntitlementService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUsageService creates a new UsageService
|
|
||||||
func NewUsageService(db *database.DB, entitlementService *EntitlementService) *UsageService {
|
|
||||||
return &UsageService{
|
|
||||||
db: db,
|
|
||||||
entitlementService: entitlementService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TrackUsage tracks usage for a user
|
|
||||||
func (s *UsageService) TrackUsage(ctx context.Context, userIDStr, usageType string, quantity int) error {
|
|
||||||
userID, err := uuid.Parse(userIDStr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid user ID: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current period start (beginning of current month)
|
|
||||||
now := time.Now()
|
|
||||||
periodStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
// Upsert usage summary
|
|
||||||
query := `
|
|
||||||
INSERT INTO usage_summary (user_id, usage_type, period_start, total_count)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
ON CONFLICT (user_id, usage_type, period_start) DO UPDATE SET
|
|
||||||
total_count = usage_summary.total_count + EXCLUDED.total_count,
|
|
||||||
updated_at = NOW()
|
|
||||||
`
|
|
||||||
|
|
||||||
_, err = s.db.Pool.Exec(ctx, query, userID, usageType, periodStart, quantity)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to track usage: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also update entitlements cache
|
|
||||||
return s.entitlementService.IncrementUsage(ctx, userID, usageType, quantity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUsageSummary returns usage summary for a user
|
|
||||||
func (s *UsageService) GetUsageSummary(ctx context.Context, userID uuid.UUID) (*models.UsageInfo, error) {
|
|
||||||
// Get entitlements (which include current usage)
|
|
||||||
ent, err := s.entitlementService.getUserEntitlements(ctx, userID)
|
|
||||||
if err != nil || ent == nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate percentages
|
|
||||||
aiPercent := 0.0
|
|
||||||
if ent.AIRequestsLimit > 0 {
|
|
||||||
aiPercent = float64(ent.AIRequestsUsed) / float64(ent.AIRequestsLimit) * 100
|
|
||||||
}
|
|
||||||
|
|
||||||
docPercent := 0.0
|
|
||||||
if ent.DocumentsLimit > 0 {
|
|
||||||
docPercent = float64(ent.DocumentsUsed) / float64(ent.DocumentsLimit) * 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get period dates
|
|
||||||
now := time.Now()
|
|
||||||
periodStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
periodEnd := periodStart.AddDate(0, 1, 0).Add(-time.Second)
|
|
||||||
|
|
||||||
return &models.UsageInfo{
|
|
||||||
AIRequestsUsed: ent.AIRequestsUsed,
|
|
||||||
AIRequestsLimit: ent.AIRequestsLimit,
|
|
||||||
AIRequestsPercent: aiPercent,
|
|
||||||
DocumentsUsed: ent.DocumentsUsed,
|
|
||||||
DocumentsLimit: ent.DocumentsLimit,
|
|
||||||
DocumentsPercent: docPercent,
|
|
||||||
PeriodStart: periodStart.Format("2006-01-02"),
|
|
||||||
PeriodEnd: periodEnd.Format("2006-01-02"),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckUsageAllowed checks if a user is allowed to perform a usage action
|
|
||||||
func (s *UsageService) CheckUsageAllowed(ctx context.Context, userIDStr, usageType string) (*models.CheckUsageResponse, error) {
|
|
||||||
userID, err := uuid.Parse(userIDStr)
|
|
||||||
if err != nil {
|
|
||||||
return &models.CheckUsageResponse{
|
|
||||||
Allowed: false,
|
|
||||||
Message: "Invalid user ID",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get entitlements
|
|
||||||
ent, err := s.entitlementService.getUserEntitlements(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return &models.CheckUsageResponse{
|
|
||||||
Allowed: false,
|
|
||||||
Message: "Failed to get entitlements",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if ent == nil {
|
|
||||||
return &models.CheckUsageResponse{
|
|
||||||
Allowed: false,
|
|
||||||
Message: "No subscription found",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentUsage, limit int
|
|
||||||
switch usageType {
|
|
||||||
case "ai_request":
|
|
||||||
currentUsage = ent.AIRequestsUsed
|
|
||||||
limit = ent.AIRequestsLimit
|
|
||||||
case "document_created":
|
|
||||||
currentUsage = ent.DocumentsUsed
|
|
||||||
limit = ent.DocumentsLimit
|
|
||||||
default:
|
|
||||||
return &models.CheckUsageResponse{
|
|
||||||
Allowed: true,
|
|
||||||
Message: "Unknown usage type - allowing",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining := limit - currentUsage
|
|
||||||
allowed := remaining > 0
|
|
||||||
|
|
||||||
response := &models.CheckUsageResponse{
|
|
||||||
Allowed: allowed,
|
|
||||||
CurrentUsage: currentUsage,
|
|
||||||
Limit: limit,
|
|
||||||
Remaining: remaining,
|
|
||||||
}
|
|
||||||
|
|
||||||
if !allowed {
|
|
||||||
response.Message = fmt.Sprintf("Usage limit reached for %s (%d/%d)", usageType, currentUsage, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUsageHistory returns usage history for a user
|
|
||||||
func (s *UsageService) GetUsageHistory(ctx context.Context, userID uuid.UUID, months int) ([]models.UsageSummary, error) {
|
|
||||||
query := `
|
|
||||||
SELECT id, user_id, usage_type, period_start, total_count, created_at, updated_at
|
|
||||||
FROM usage_summary
|
|
||||||
WHERE user_id = $1
|
|
||||||
AND period_start >= $2
|
|
||||||
ORDER BY period_start DESC, usage_type
|
|
||||||
`
|
|
||||||
|
|
||||||
// Calculate start date
|
|
||||||
startDate := time.Now().AddDate(0, -months, 0)
|
|
||||||
startDate = time.Date(startDate.Year(), startDate.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
rows, err := s.db.Pool.Query(ctx, query, userID, startDate)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var summaries []models.UsageSummary
|
|
||||||
for rows.Next() {
|
|
||||||
var summary models.UsageSummary
|
|
||||||
err := rows.Scan(
|
|
||||||
&summary.ID, &summary.UserID, &summary.UsageType,
|
|
||||||
&summary.PeriodStart, &summary.TotalCount,
|
|
||||||
&summary.CreatedAt, &summary.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
summaries = append(summaries, summary)
|
|
||||||
}
|
|
||||||
|
|
||||||
return summaries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetPeriodUsage resets usage for a new billing period
|
|
||||||
func (s *UsageService) ResetPeriodUsage(ctx context.Context, userID uuid.UUID) error {
|
|
||||||
now := time.Now()
|
|
||||||
newPeriodStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
newPeriodEnd := newPeriodStart.AddDate(0, 1, 0).Add(-time.Second)
|
|
||||||
|
|
||||||
return s.entitlementService.ResetUsageCounters(ctx, userID, &newPeriodStart, &newPeriodEnd)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,891 @@
|
|||||||
|
import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/DevPortalLayout'
|
||||||
|
|
||||||
|
export default function ComplianceServiceDocsPage() {
|
||||||
|
return (
|
||||||
|
<DevPortalLayout
|
||||||
|
title="Wie funktioniert der Compliance Service?"
|
||||||
|
description="Eine umfassende Erklaerung des gesamten Systems -- vom Rechtstext bis zur Compliance-Bewertung."
|
||||||
|
>
|
||||||
|
{/* ============================================================ */}
|
||||||
|
{/* 1. EINLEITUNG */}
|
||||||
|
{/* ============================================================ */}
|
||||||
|
<h2 id="einfuehrung">1. Was ist der Compliance Hub?</h2>
|
||||||
|
<p>
|
||||||
|
Der <strong>BreakPilot Compliance Hub</strong> ist ein System, das Organisationen dabei
|
||||||
|
unterstuetzt, gesetzliche Vorschriften einzuhalten. Er beantwortet die zentrale Frage:
|
||||||
|
</p>
|
||||||
|
<blockquote>
|
||||||
|
<em>“Duerfen wir das, was wir vorhaben, ueberhaupt so machen -- und wenn ja, welche
|
||||||
|
Auflagen muessen wir dafuer erfuellen?”</em>
|
||||||
|
</blockquote>
|
||||||
|
<p>
|
||||||
|
Konkret geht es um EU- und deutsche Gesetze, die fuer den Umgang mit Daten und
|
||||||
|
kuenstlicher Intelligenz relevant sind: die <strong>DSGVO</strong>, den <strong>AI Act</strong>,
|
||||||
|
die <strong>NIS2-Richtlinie</strong> und viele weitere Regelwerke. Das System hat vier
|
||||||
|
Hauptaufgaben:
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
<strong>Wissen bereitstellen:</strong> Hunderte Rechtstexte sind eingelesen und
|
||||||
|
durchsuchbar -- nicht nur per Stichwort, sondern nach Bedeutung (semantische Suche).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Bewerten:</strong> Wenn ein Nutzer einen geplanten KI-Anwendungsfall beschreibt,
|
||||||
|
bewertet das System automatisch, ob er zulaessig ist, welches Risiko besteht und welche
|
||||||
|
Massnahmen noetig sind.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Dokumentieren:</strong> Das System erzeugt die Dokumente, die Aufsichtsbehoerden
|
||||||
|
verlangen: Datenschutz-Folgenabschaetzungen (DSFA), technisch-organisatorische Massnahmen
|
||||||
|
(TOM), Verarbeitungsverzeichnisse (VVT) und mehr.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Nachweisen:</strong> Jede Bewertung, jede Entscheidung und jeder Zugriff wird
|
||||||
|
revisionssicher protokolliert -- als Nachweis gegenueber Pruefer und Behoerden.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<InfoBox type="info" title="Kern-Designprinzip">
|
||||||
|
<strong>Die KI ist nicht die Entscheidungsinstanz.</strong> Alle
|
||||||
|
Compliance-Entscheidungen (zulaessig / bedingt zulaessig / nicht zulaessig) trifft ein
|
||||||
|
deterministisches Regelwerk. Das LLM (Sprachmodell) wird ausschliesslich dafuer verwendet,
|
||||||
|
Ergebnisse verstaendlich zu <em>erklaeren</em> -- niemals um sie zu <em>treffen</em>.
|
||||||
|
</InfoBox>
|
||||||
|
|
||||||
|
{/* ============================================================ */}
|
||||||
|
{/* 2. ARCHITEKTUR-UEBERSICHT */}
|
||||||
|
{/* ============================================================ */}
|
||||||
|
<h2 id="architektur">2. Architektur im Ueberblick</h2>
|
||||||
|
<p>
|
||||||
|
Das System besteht aus mehreren Bausteinen, die jeweils eine klar abgegrenzte Aufgabe haben.
|
||||||
|
Man kann es sich wie ein Buero vorstellen:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="not-prose my-6 overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Baustein</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Analogie</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Technologie</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Aufgabe</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
<tr><td className="px-4 py-3 font-medium">API-Gateway</td><td className="px-4 py-3">Empfang / Rezeption</td><td className="px-4 py-3">Go (Gin)</td><td className="px-4 py-3">Nimmt alle Anfragen entgegen, prueft Identitaet und leitet weiter</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Compliance Engine (UCCA)</td><td className="px-4 py-3">Sachbearbeiter</td><td className="px-4 py-3">Go</td><td className="px-4 py-3">Bewertet Anwendungsfaelle gegen 45+ Regeln und berechnet Risikoscore</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">RAG Service</td><td className="px-4 py-3">Rechtsbibliothek</td><td className="px-4 py-3">Python (FastAPI)</td><td className="px-4 py-3">Durchsucht Gesetze semantisch und beantwortet Rechtsfragen</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Legal Corpus</td><td className="px-4 py-3">Gesetzesbuecher im Regal</td><td className="px-4 py-3">YAML/JSON + Qdrant</td><td className="px-4 py-3">Enthaelt alle Rechtstexte als durchsuchbare Wissensbasis</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Policy Engine</td><td className="px-4 py-3">Regelbuch des Sachbearbeiters</td><td className="px-4 py-3">YAML-Dateien</td><td className="px-4 py-3">45+ auditierbare Pruefregeln in maschinenlesbarer Form</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Eskalations-System</td><td className="px-4 py-3">Chef-Unterschrift</td><td className="px-4 py-3">Go + PostgreSQL</td><td className="px-4 py-3">Leitet kritische Faelle an menschliche Pruefer weiter</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Admin Dashboard</td><td className="px-4 py-3">Schreibtisch</td><td className="px-4 py-3">Next.js</td><td className="px-4 py-3">Benutzeroberflaeche fuer alle Funktionen</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">PostgreSQL</td><td className="px-4 py-3">Aktenschrank</td><td className="px-4 py-3">SQL-Datenbank</td><td className="px-4 py-3">Speichert Assessments, Eskalationen, Controls, Audit-Trail</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Qdrant</td><td className="px-4 py-3">Suchindex der Bibliothek</td><td className="px-4 py-3">Vektordatenbank</td><td className="px-4 py-3">Ermoeglicht semantische Suche ueber Rechtstexte</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Wie die Bausteine zusammenspielen</h3>
|
||||||
|
<CodeBlock language="text" filename="Datenfluss: Vom Benutzer zur Compliance-Bewertung">
|
||||||
|
{`Benutzer (Browser)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ API-Gateway (Port 8080) │ ← Authentifizierung, Rate-Limiting, Tenant-Isolation
|
||||||
|
│ "Wer bist du? Darfst du?" │
|
||||||
|
└──────────┬──────────────────┘
|
||||||
|
|
|
||||||
|
┌─────┼──────────────────────────────┐
|
||||||
|
v v v
|
||||||
|
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
|
│ Compliance │ │ RAG Service │ │ Security │
|
||||||
|
│ Engine │ │ (Bibliothek)│ │ Scanner │
|
||||||
|
│ (Bewertung) │ │ │ │ │
|
||||||
|
└──────┬───┬──┘ └──────┬───────┘ └──────────────┘
|
||||||
|
| | |
|
||||||
|
| | ┌──────┴───────┐
|
||||||
|
| | │ Qdrant │ ← Vektordatenbank mit allen Rechtstexten
|
||||||
|
| | │ (Suchindex) │
|
||||||
|
| | └──────────────┘
|
||||||
|
| |
|
||||||
|
| └──────────────────────┐
|
||||||
|
v v
|
||||||
|
┌──────────────┐ ┌──────────────┐
|
||||||
|
│ PostgreSQL │ │ Eskalation │
|
||||||
|
│ (Speicher) │ │ (E0-E3) │
|
||||||
|
└──────────────┘ └──────────────┘`}
|
||||||
|
</CodeBlock>
|
||||||
|
|
||||||
|
{/* ============================================================ */}
|
||||||
|
{/* 3. DER KATALOGMANAGER / LEGAL CORPUS */}
|
||||||
|
{/* ============================================================ */}
|
||||||
|
<h2 id="katalogmanager">3. Der Katalogmanager: Die Wissensbasis</h2>
|
||||||
|
<p>
|
||||||
|
Das Herzstueck des Systems ist seine <strong>Wissensbasis</strong> -- eine Sammlung aller
|
||||||
|
relevanten Rechtstexte, die das System kennt und durchsuchen kann. Wir nennen das den
|
||||||
|
<strong> Legal Corpus</strong> (wörtlich: “Rechtlicher Koerper”).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>3.1 Welche Dokumente sind enthalten?</h3>
|
||||||
|
<p>
|
||||||
|
Der Legal Corpus ist in zwei Hauptbereiche gegliedert: <strong>EU-Recht</strong> und
|
||||||
|
<strong> deutsches Recht</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>EU-Verordnungen und -Richtlinien</h4>
|
||||||
|
<div className="not-prose my-4 overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead className="bg-blue-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-700">Regelwerk</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-700">Abkuerzung</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-700">Artikel</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-700">Gueltig seit</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-700">Thema</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Datenschutz-Grundverordnung</td><td className="px-4 py-3">DSGVO</td><td className="px-4 py-3">99</td><td className="px-4 py-3">25.05.2018</td><td className="px-4 py-3">Schutz personenbezogener Daten</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">KI-Verordnung</td><td className="px-4 py-3">AI Act</td><td className="px-4 py-3">113</td><td className="px-4 py-3">01.08.2024</td><td className="px-4 py-3">Regulierung kuenstlicher Intelligenz</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Netz- und Informationssicherheit</td><td className="px-4 py-3">NIS2</td><td className="px-4 py-3">46</td><td className="px-4 py-3">18.10.2024</td><td className="px-4 py-3">Cybersicherheit kritischer Infrastrukturen</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">ePrivacy-Verordnung</td><td className="px-4 py-3">ePrivacy</td><td className="px-4 py-3">--</td><td className="px-4 py-3">in Arbeit</td><td className="px-4 py-3">Vertraulichkeit elektronischer Kommunikation</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Cyber Resilience Act</td><td className="px-4 py-3">CRA</td><td className="px-4 py-3">--</td><td className="px-4 py-3">2024</td><td className="px-4 py-3">Cybersicherheit von Produkten mit digitalen Elementen</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Data Act</td><td className="px-4 py-3">DA</td><td className="px-4 py-3">--</td><td className="px-4 py-3">2024</td><td className="px-4 py-3">Zugang und Nutzung von Daten</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Digital Markets Act</td><td className="px-4 py-3">DMA</td><td className="px-4 py-3">--</td><td className="px-4 py-3">2023</td><td className="px-4 py-3">Regulierung digitaler Gatekeeper</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Deutsches Recht</h4>
|
||||||
|
<div className="not-prose my-4 overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead className="bg-green-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-700">Gesetz</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-700">Abkuerzung</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-700">Thema</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Telekommunikation-Digitale-Dienste-Datenschutz-Gesetz</td><td className="px-4 py-3">TDDDG</td><td className="px-4 py-3">Datenschutz bei Telekommunikation und digitalen Diensten</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Bundesdatenschutzgesetz</td><td className="px-4 py-3">BDSG</td><td className="px-4 py-3">Nationale Ergaenzung zur DSGVO</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">IT-Sicherheitsgesetz</td><td className="px-4 py-3">IT-SiG</td><td className="px-4 py-3">IT-Sicherheit kritischer Infrastrukturen</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">BSI-KritisV</td><td className="px-4 py-3">KritisV</td><td className="px-4 py-3">BSI-Verordnung fuer kritische Infrastrukturen</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Standards und Normen</h4>
|
||||||
|
<div className="not-prose my-4 overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead className="bg-purple-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-700">Standard</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-700">Thema</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
<tr><td className="px-4 py-3 font-medium">ISO 27001</td><td className="px-4 py-3">Informationssicherheits-Managementsystem (ISMS)</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">SOC2</td><td className="px-4 py-3">Trust Service Criteria (Sicherheit, Verfuegbarkeit, Vertraulichkeit)</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">BSI Grundschutz</td><td className="px-4 py-3">IT-Grundschutz des BSI</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">BSI TR-03161</td><td className="px-4 py-3">Technische Richtlinie fuer Anforderungen an Anwendungen im Gesundheitswesen</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">SCC (Standard Contractual Clauses)</td><td className="px-4 py-3">Standardvertragsklauseln fuer Drittlandtransfers</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>3.2 Wie werden Rechtstexte gespeichert?</h3>
|
||||||
|
<p>
|
||||||
|
Jeder Rechtstext durchlaeuft eine <strong>Verarbeitungspipeline</strong>, bevor er im
|
||||||
|
System durchsuchbar ist. Der Vorgang laesst sich mit dem Erstellen eines
|
||||||
|
Bibliothekskatalogs vergleichen:
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
<strong>Erfassung (Ingestion):</strong> Der Rechtstext wird als Dokument (PDF, Markdown
|
||||||
|
oder Klartext) in das System geladen. Fuer jede Verordnung gibt es eine
|
||||||
|
<code>metadata.json</code>-Datei, die beschreibt, um welches Gesetz es sich handelt,
|
||||||
|
wie viele Artikel es hat und welche Schluesselbegriffe relevant sind.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Zerkleinerung (Chunking):</strong> Lange Gesetzestexte werden in kleinere
|
||||||
|
Abschnitte von ca. 512 Zeichen zerlegt. Dabei ueberlappen sich die Abschnitte um
|
||||||
|
50 Zeichen, damit kein Kontext verloren geht. Stellen Sie sich vor, Sie zerschneiden
|
||||||
|
einen langen Brief in Absaetze, wobei jeder Absatz die letzten zwei Zeilen des
|
||||||
|
vorherigen enthaelt.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Vektorisierung (Embedding):</strong> Jeder Textabschnitt wird vom
|
||||||
|
Embedding-Modell <strong>BGE-M3</strong> in einen <em>Vektor</em> umgewandelt -- eine
|
||||||
|
Liste von 1.024 Zahlen, die die <em>Bedeutung</em> des Textes repraesentieren. Texte
|
||||||
|
mit aehnlicher Bedeutung haben aehnliche Vektoren, unabhaengig von der Wortwahl.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Indexierung:</strong> Die Vektoren werden in der Vektordatenbank
|
||||||
|
<strong> Qdrant</strong> gespeichert. Zusammen mit jedem Vektor werden Metadaten
|
||||||
|
hinterlegt: zu welchem Gesetz der Text gehoert, welcher Artikel es ist und welcher
|
||||||
|
Paragraph.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<CodeBlock language="text" filename="Verarbeitungspipeline: Vom Gesetzestext zur Suche">
|
||||||
|
{`Rechtstext (z.B. DSGVO Art. 32)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
┌────────────────────────┐
|
||||||
|
│ 1. Einlesen │ ← PDF/Markdown/Klartext + metadata.json
|
||||||
|
│ Metadaten zuordnen │
|
||||||
|
└──────────┬─────────────┘
|
||||||
|
|
|
||||||
|
v
|
||||||
|
┌────────────────────────┐
|
||||||
|
│ 2. Chunking │ ← Text in 512-Zeichen-Abschnitte zerlegen
|
||||||
|
│ Ueberlappung: 50 Zch. │ (mit 50 Zeichen Ueberlappung)
|
||||||
|
└──────────┬─────────────┘
|
||||||
|
|
|
||||||
|
v
|
||||||
|
┌────────────────────────┐
|
||||||
|
│ 3. Embedding │ ← BGE-M3 wandelt Text in 1024 Zahlen um
|
||||||
|
│ Text → Vektor │ (Bedeutungs-Repraesentation)
|
||||||
|
└──────────┬─────────────┘
|
||||||
|
|
|
||||||
|
v
|
||||||
|
┌────────────────────────┐
|
||||||
|
│ 4. Qdrant speichern │ ← Vektor + Metadaten werden indexiert
|
||||||
|
│ Sofort durchsuchbar │ (~2.274 Chunks insgesamt)
|
||||||
|
└────────────────────────┘`}
|
||||||
|
</CodeBlock>
|
||||||
|
|
||||||
|
<InfoBox type="success" title="Aktueller Bestand">
|
||||||
|
Der Legal Corpus enthaelt derzeit ca. <strong>2.274 Textabschnitte</strong> aus ueber
|
||||||
|
400 Gesetzesartikeln. Darunter 99 DSGVO-Artikel, 85 AI-Act-Artikel, 46 NIS2-Artikel,
|
||||||
|
86 BDSG-Paragraphen sowie zahlreiche Artikel aus TDDDG, CRA, Data Act und weiteren
|
||||||
|
Regelwerken.
|
||||||
|
</InfoBox>
|
||||||
|
|
||||||
|
<h3>3.3 Wie funktioniert die semantische Suche?</h3>
|
||||||
|
<p>
|
||||||
|
Klassische Suchmaschinen suchen nach <em>Woertern</em>. Wenn Sie “Einwilligung”
|
||||||
|
eingeben, finden sie nur Texte, die genau dieses Wort enthalten. Unsere semantische Suche
|
||||||
|
funktioniert anders: Sie sucht nach <em>Bedeutung</em>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Beispiel:</strong> Wenn Sie fragen “Wann muss ich den Nutzer um Erlaubnis
|
||||||
|
bitten?”, findet das System Art. 7 DSGVO (Bedingungen fuer die Einwilligung), obwohl
|
||||||
|
Ihre Frage das Wort “Einwilligung” gar nicht enthaelt. Das funktioniert, weil
|
||||||
|
die Bedeutungsvektoren von “um Erlaubnis bitten” und “Einwilligung”
|
||||||
|
sehr aehnlich sind.
|
||||||
|
</p>
|
||||||
|
<p>Der Suchvorgang im Detail:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Ihre Suchanfrage wird vom gleichen Modell (BGE-M3) in einen Vektor umgewandelt.</li>
|
||||||
|
<li>Qdrant vergleicht diesen Vektor mit allen gespeicherten Vektoren (Kosinus-Aehnlichkeit).</li>
|
||||||
|
<li>Die aehnlichsten Textabschnitte werden zurueckgegeben, sortiert nach Relevanz (Score 0-1).</li>
|
||||||
|
<li>Optional kann nach bestimmten Gesetzen gefiltert werden (nur DSGVO, nur AI Act, etc.).</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>3.4 Der KI-Rechtsassistent (Legal Q&A)</h3>
|
||||||
|
<p>
|
||||||
|
Ueber die reine Suche hinaus kann das System auch <strong>Fragen beantworten</strong>.
|
||||||
|
Dabei wird die semantische Suche mit einem Sprachmodell kombiniert:
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Suche:</strong> Das System findet die 5 relevantesten Gesetzesabschnitte zur Frage.</li>
|
||||||
|
<li><strong>Kontext-Erstellung:</strong> Diese Abschnitte werden zusammen mit der Frage an das Sprachmodell (Qwen 2.5 32B) uebergeben.</li>
|
||||||
|
<li><strong>Antwort-Generierung:</strong> Das Modell formuliert eine verstaendliche Antwort auf Deutsch und zitiert die verwendeten Rechtsquellen.</li>
|
||||||
|
<li><strong>Quellenangabe:</strong> Jede Antwort enthaelt exakte Zitate mit Artikelangaben, damit die Aussagen nachpruefbar sind.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<InfoBox type="warning" title="Wichtige Einschraenkung">
|
||||||
|
Der Rechtsassistent gibt <strong>keine Rechtsberatung</strong>. Er hilft, relevante
|
||||||
|
Gesetzespassagen zu finden und verstaendlich zusammenzufassen. Die Antworten enthalten
|
||||||
|
immer einen Confidence-Score (0-1), der angibt, wie sicher sich das System ist. Bei
|
||||||
|
niedrigem Score wird explizit auf die Unsicherheit hingewiesen.
|
||||||
|
</InfoBox>
|
||||||
|
|
||||||
|
{/* ============================================================ */}
|
||||||
|
{/* 4. DIE COMPLIANCE ENGINE (UCCA) */}
|
||||||
|
{/* ============================================================ */}
|
||||||
|
<h2 id="compliance-engine">4. Die Compliance Engine: Wie Bewertungen funktionieren</h2>
|
||||||
|
<p>
|
||||||
|
Das Kernmodul des Compliance Hub ist die <strong>UCCA Engine</strong> (Unified Compliance
|
||||||
|
Control Assessment). Sie bewertet, ob ein geplanter KI-Anwendungsfall zulaessig ist.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>4.1 Der Fragebogen (Use Case Intake)</h3>
|
||||||
|
<p>
|
||||||
|
Alles beginnt mit einem strukturierten Fragebogen. Der Nutzer beschreibt seinen geplanten
|
||||||
|
Anwendungsfall, indem er Fragen zu folgenden Bereichen beantwortet:
|
||||||
|
</p>
|
||||||
|
<div className="not-prose my-4 overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Bereich</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Typische Fragen</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Warum relevant?</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Datentypen</td><td className="px-4 py-3">Werden personenbezogene Daten verarbeitet? Besondere Kategorien (Art. 9)?</td><td className="px-4 py-3">Art. 9-Daten (Gesundheit, Religion, etc.) erfordern besondere Schutzmassnahmen</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Verarbeitungszweck</td><td className="px-4 py-3">Wird Profiling betrieben? Scoring? Automatisierte Entscheidungen?</td><td className="px-4 py-3">Art. 22 DSGVO schuetzt vor vollautomatischen Entscheidungen</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Modellnutzung</td><td className="px-4 py-3">Wird das Modell nur genutzt (Inference) oder mit Nutzerdaten trainiert (Fine-Tuning)?</td><td className="px-4 py-3">Training mit personenbezogenen Daten erfordert besondere Rechtsgrundlage</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Automatisierungsgrad</td><td className="px-4 py-3">Assistenzsystem, teil- oder vollautomatisch?</td><td className="px-4 py-3">Vollautomatische Systeme unterliegen strengeren Auflagen</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Datenspeicherung</td><td className="px-4 py-3">Wie lange werden Daten gespeichert? Wo?</td><td className="px-4 py-3">DSGVO Art. 5: Speicherbegrenzung / Zweckbindung</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Hosting-Standort</td><td className="px-4 py-3">EU, USA, oder anderswo?</td><td className="px-4 py-3">Drittlandtransfers erfordern zusaetzliche Garantien (SCC, DPF)</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Branche</td><td className="px-4 py-3">Gesundheit, Finanzen, Bildung, Automotive, ...?</td><td className="px-4 py-3">Bestimmte Branchen unterliegen zusaetzlichen Regulierungen</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Menschliche Aufsicht</td><td className="px-4 py-3">Gibt es einen Human-in-the-Loop?</td><td className="px-4 py-3">AI Act fordert menschliche Aufsicht fuer Hochrisiko-KI</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>4.2 Die Pruefregeln (Policy Engine)</h3>
|
||||||
|
<p>
|
||||||
|
Die Antworten des Fragebogens werden gegen ein <strong>Regelwerk von ueber 45 Regeln</strong>
|
||||||
|
geprueft. Jede Regel ist in einer YAML-Datei definiert und hat folgende Struktur:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Bedingung:</strong> Wann greift die Regel? (z.B. “Art. 9-Daten werden verarbeitet”)</li>
|
||||||
|
<li><strong>Schweregrad:</strong> INFO (Hinweis), WARN (Risiko, aber loesbar) oder BLOCK (grundsaetzlich nicht zulaessig)</li>
|
||||||
|
<li><strong>Auswirkung:</strong> Was passiert, wenn die Regel greift? (Risikoerhoehung, zusaetzliche Controls, Eskalation)</li>
|
||||||
|
<li><strong>Gesetzesreferenz:</strong> Auf welchen Artikel bezieht sich die Regel?</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Die Regeln sind in <strong>10 Kategorien</strong> organisiert:</p>
|
||||||
|
<div className="not-prose my-4 overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Kategorie</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Regel-IDs</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Prueft</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Beispiel</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
<tr><td className="px-4 py-3 font-medium">A. Datenklassifikation</td><td className="px-4 py-3">R-001 bis R-006</td><td className="px-4 py-3">Welche Daten werden verarbeitet?</td><td className="px-4 py-3">R-001: Werden personenbezogene Daten verarbeitet? → +10 Risiko</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">B. Zweck & Kontext</td><td className="px-4 py-3">R-010 bis R-013</td><td className="px-4 py-3">Warum und wie werden Daten genutzt?</td><td className="px-4 py-3">R-011: Profiling? → DSFA empfohlen</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">C. Automatisierung</td><td className="px-4 py-3">R-020 bis R-025</td><td className="px-4 py-3">Wie stark ist die Automatisierung?</td><td className="px-4 py-3">R-023: Vollautomatisch? → Art. 22 Risiko</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">D. Training vs. Nutzung</td><td className="px-4 py-3">R-030 bis R-035</td><td className="px-4 py-3">Wird das Modell trainiert?</td><td className="px-4 py-3">R-035: Training + Art. 9-Daten? → BLOCK</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">E. Speicherung</td><td className="px-4 py-3">R-040 bis R-042</td><td className="px-4 py-3">Wie lange werden Daten gespeichert?</td><td className="px-4 py-3">R-041: Unbegrenzte Speicherung? → WARN</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">F. Hosting</td><td className="px-4 py-3">R-050 bis R-052</td><td className="px-4 py-3">Wo werden Daten gehostet?</td><td className="px-4 py-3">R-051: Hosting in USA? → SCC/DPF pruefen</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">G. Transparenz</td><td className="px-4 py-3">R-060 bis R-062</td><td className="px-4 py-3">Werden Nutzer informiert?</td><td className="px-4 py-3">R-060: Keine Offenlegung? → AI Act Verstoss</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">H. Branchenspezifisch</td><td className="px-4 py-3">R-070 bis R-074</td><td className="px-4 py-3">Gelten Sonderregeln fuer die Branche?</td><td className="px-4 py-3">R-070: Gesundheitsbranche? → zusaetzliche Anforderungen</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">I. Aggregation</td><td className="px-4 py-3">R-090 bis R-092</td><td className="px-4 py-3">Meta-Regeln ueber andere Regeln</td><td className="px-4 py-3">R-090: Zu viele WARN-Regeln? → Gesamtrisiko erhoeht</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">J. Erklaerung</td><td className="px-4 py-3">R-100</td><td className="px-4 py-3">Warum hat das System so entschieden?</td><td className="px-4 py-3">Automatisch generierte Begruendung</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InfoBox type="info" title="Warum YAML-Regeln statt Code?">
|
||||||
|
Die Regeln sind bewusst in YAML-Dateien definiert und nicht im Programmcode versteckt.
|
||||||
|
Das hat zwei Vorteile: (1) Sie sind fuer Nicht-Programmierer lesbar und damit
|
||||||
|
<strong> auditierbar</strong>, d.h. ein Datenschutzbeauftragter oder Wirtschaftspruefer kann
|
||||||
|
pruefen, ob die Regeln korrekt sind. (2) Sie koennen <strong>versioniert</strong> werden --
|
||||||
|
wenn sich ein Gesetz aendert, wird die Regelaenderung im Versionsverlauf sichtbar.
|
||||||
|
</InfoBox>
|
||||||
|
|
||||||
|
<h3>4.3 Das Ergebnis: Die Compliance-Bewertung</h3>
|
||||||
|
<p>
|
||||||
|
Nach der Pruefung aller Regeln erhaelt der Nutzer eine strukturierte Bewertung:
|
||||||
|
</p>
|
||||||
|
<div className="not-prose my-4 overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Ergebnis</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Beschreibung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-medium">Machbarkeit</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-block px-2 py-0.5 rounded bg-green-100 text-green-800 text-xs font-bold mr-1">YES</span>
|
||||||
|
<span className="inline-block px-2 py-0.5 rounded bg-yellow-100 text-yellow-800 text-xs font-bold mr-1">CONDITIONAL</span>
|
||||||
|
<span className="inline-block px-2 py-0.5 rounded bg-red-100 text-red-800 text-xs font-bold">NO</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Risikoscore</td><td className="px-4 py-3">0-100 Punkte. Je hoeher, desto mehr Massnahmen sind erforderlich.</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Risikostufe</td><td className="px-4 py-3">MINIMAL / LOW / MEDIUM / HIGH / UNACCEPTABLE</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Ausgeloeste Regeln</td><td className="px-4 py-3">Liste aller Regeln, die angeschlagen haben, mit Schweregrad und Gesetzesreferenz</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Erforderliche Controls</td><td className="px-4 py-3">Konkrete Massnahmen, die umgesetzt werden muessen (z.B. Verschluesselung, Einwilligung einholen)</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Empfohlene Architektur</td><td className="px-4 py-3">Technische Muster, die eingesetzt werden sollten (z.B. On-Premise statt Cloud)</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Verbotene Muster</td><td className="px-4 py-3">Technische Ansaetze, die vermieden werden muessen</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">DSFA erforderlich?</td><td className="px-4 py-3">Ob eine Datenschutz-Folgenabschaetzung nach Art. 35 DSGVO durchgefuehrt werden muss</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CodeBlock language="text" filename="Beispiel: Bewertung eines Chatbots mit Kundendaten">
|
||||||
|
{`Anwendungsfall: "Chatbot fuer Kundenservice mit Zugriff auf Bestellhistorie"
|
||||||
|
|
||||||
|
Machbarkeit: CONDITIONAL (bedingt zulaessig)
|
||||||
|
Risikoscore: 35/100 (LOW)
|
||||||
|
Risikostufe: LOW
|
||||||
|
|
||||||
|
Ausgeloeste Regeln:
|
||||||
|
R-001 WARN Personenbezogene Daten werden verarbeitet (Art. 6 DSGVO)
|
||||||
|
R-010 INFO Verarbeitungszweck: Kundenservice (Art. 5 DSGVO)
|
||||||
|
R-020 INFO Assistenzsystem (nicht vollautomatisch) (Art. 22 DSGVO)
|
||||||
|
R-060 WARN Nutzer muessen ueber KI-Nutzung informiert werden (AI Act Art. 52)
|
||||||
|
|
||||||
|
Erforderliche Controls:
|
||||||
|
C_EXPLICIT_CONSENT Einwilligung fuer Chatbot-Nutzung einholen
|
||||||
|
C_TRANSPARENCY Hinweis "Sie sprechen mit einer KI"
|
||||||
|
C_DATA_MINIMIZATION Nur notwendige Bestelldaten abrufen
|
||||||
|
|
||||||
|
DSFA erforderlich: Nein (Risikoscore unter 40)
|
||||||
|
Eskalation: E0 (keine manuelle Pruefung noetig)`}
|
||||||
|
</CodeBlock>
|
||||||
|
|
||||||
|
{/* ============================================================ */}
|
||||||
|
{/* 5. DAS ESKALATIONS-SYSTEM */}
|
||||||
|
{/* ============================================================ */}
|
||||||
|
<h2 id="eskalation">5. Das Eskalations-System: Wann Menschen entscheiden</h2>
|
||||||
|
<p>
|
||||||
|
Nicht jede Bewertung ist eindeutig. Fuer heikle Faelle gibt es ein abgestuftes
|
||||||
|
Eskalations-System, das sicherstellt, dass die richtigen Menschen die endgueltige
|
||||||
|
Entscheidung treffen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="not-prose my-6 overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Stufe</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Wann?</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Wer prueft?</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Frist (SLA)</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Beispiel</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
<tr className="bg-green-50"><td className="px-4 py-3 font-bold text-green-800">E0</td><td className="px-4 py-3">Nur INFO-Regeln, Risiko < 20</td><td className="px-4 py-3">Niemand (automatisch freigegeben)</td><td className="px-4 py-3">--</td><td className="px-4 py-3">Spam-Filter ohne personenbezogene Daten</td></tr>
|
||||||
|
<tr className="bg-yellow-50"><td className="px-4 py-3 font-bold text-yellow-800">E1</td><td className="px-4 py-3">WARN-Regeln, Risiko 20-39</td><td className="px-4 py-3">Teamleiter</td><td className="px-4 py-3">24 Stunden</td><td className="px-4 py-3">Chatbot mit Kundendaten (unser Beispiel oben)</td></tr>
|
||||||
|
<tr className="bg-orange-50"><td className="px-4 py-3 font-bold text-orange-800">E2</td><td className="px-4 py-3">Art. 9-Daten ODER Risiko 40-59 ODER DSFA empfohlen</td><td className="px-4 py-3">Datenschutzbeauftragter (DSB)</td><td className="px-4 py-3">8 Stunden</td><td className="px-4 py-3">KI-System, das Gesundheitsdaten verarbeitet</td></tr>
|
||||||
|
<tr className="bg-red-50"><td className="px-4 py-3 font-bold text-red-800">E3</td><td className="px-4 py-3">BLOCK-Regel ODER Risiko ≥ 60 ODER Art. 22-Risiko</td><td className="px-4 py-3">DSB + Rechtsabteilung</td><td className="px-4 py-3">4 Stunden</td><td className="px-4 py-3">Vollautomatische Kreditentscheidung</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Zuweisung:</strong> Die Zuweisung erfolgt automatisch an den Pruefer mit der
|
||||||
|
geringsten aktuellen Arbeitslast (Workload-basiertes Round-Robin). Jeder Pruefer hat eine
|
||||||
|
konfigurierbare Obergrenze fuer gleichzeitige Reviews (z.B. 10 fuer Teamleiter, 5 fuer DSB,
|
||||||
|
3 fuer Rechtsabteilung).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Entscheidung:</strong> Der Pruefer kann den Anwendungsfall <em>freigeben</em>,
|
||||||
|
<em>ablehnen</em>, <em>mit Auflagen freigeben</em> oder <em>weiter eskalieren</em>.
|
||||||
|
Jede Entscheidung wird mit Begruendung im Audit-Trail gespeichert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ============================================================ */}
|
||||||
|
{/* 6. CONTROLS, EVIDENCE & RISIKEN */}
|
||||||
|
{/* ============================================================ */}
|
||||||
|
<h2 id="controls">6. Controls, Nachweise und Risiken</h2>
|
||||||
|
|
||||||
|
<h3>6.1 Was sind Controls?</h3>
|
||||||
|
<p>
|
||||||
|
Ein <strong>Control</strong> ist eine konkrete Massnahme, die eine Organisation umsetzt,
|
||||||
|
um ein Compliance-Risiko zu beherrschen. Es gibt drei Arten:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Technische Controls:</strong> Verschluesselung, Zugangskontrollen, Firewalls, Pseudonymisierung</li>
|
||||||
|
<li><strong>Organisatorische Controls:</strong> Schulungen, Richtlinien, Verantwortlichkeiten, Audits</li>
|
||||||
|
<li><strong>Physische Controls:</strong> Zutrittskontrolle zu Serverraeumen, Schliesssysteme</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Der Compliance Hub verwaltet einen <strong>Katalog von ueber 100 vordefinierten Controls</strong>,
|
||||||
|
die in 9 Domaenen organisiert sind:
|
||||||
|
</p>
|
||||||
|
<div className="not-prose my-4">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
{ code: 'AC', name: 'Zugriffsmanagement', desc: 'Wer darf was?' },
|
||||||
|
{ code: 'DP', name: 'Datenschutz', desc: 'Schutz personenbezogener Daten' },
|
||||||
|
{ code: 'NS', name: 'Netzwerksicherheit', desc: 'Sichere Kommunikation' },
|
||||||
|
{ code: 'IR', name: 'Incident Response', desc: 'Reaktion auf Sicherheitsvorfaelle' },
|
||||||
|
{ code: 'BC', name: 'Business Continuity', desc: 'Geschaeftskontinuitaet' },
|
||||||
|
{ code: 'VM', name: 'Vendor Management', desc: 'Dienstleister-Steuerung' },
|
||||||
|
{ code: 'AM', name: 'Asset Management', desc: 'Verwaltung von IT-Werten' },
|
||||||
|
{ code: 'CR', name: 'Kryptographie', desc: 'Verschluesselung & Schluessel' },
|
||||||
|
{ code: 'PS', name: 'Physische Sicherheit', desc: 'Gebaeude & Hardware' },
|
||||||
|
].map(d => (
|
||||||
|
<div key={d.code} className="border border-gray-200 rounded-lg p-3 text-sm">
|
||||||
|
<div className="font-bold text-blue-600">{d.code}</div>
|
||||||
|
<div className="font-medium">{d.name}</div>
|
||||||
|
<div className="text-gray-500 text-xs">{d.desc}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>6.2 Wie Controls mit Gesetzen verknuepft sind</h3>
|
||||||
|
<p>
|
||||||
|
Jeder Control ist mit einem oder mehreren Gesetzesartikeln verknuepft. Diese
|
||||||
|
<strong> Mappings</strong> machen sichtbar, warum eine Massnahme erforderlich ist:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CodeBlock language="text" filename="Beispiel: Control-Mapping">
|
||||||
|
{`Control: AC-01 (Zugriffskontrolle)
|
||||||
|
├── DSGVO Art. 32 → "Sicherheit der Verarbeitung"
|
||||||
|
├── NIS2 Art. 21 → "Massnahmen zum Management von Cyberrisiken"
|
||||||
|
├── ISO 27001 A.9 → "Zugangskontrolle"
|
||||||
|
└── BSI Grundschutz → "ORP.4 Identitaets- und Berechtigungsmanagement"
|
||||||
|
|
||||||
|
Control: DP-03 (Datenverschluesselung)
|
||||||
|
├── DSGVO Art. 32 → "Verschluesselung personenbezogener Daten"
|
||||||
|
├── DSGVO Art. 34 → "Benachrichtigung ueber Datenverletzung" (Ausnahme bei Verschluesselung)
|
||||||
|
└── NIS2 Art. 21 → "Einsatz von Kryptographie"`}
|
||||||
|
</CodeBlock>
|
||||||
|
|
||||||
|
<h3>6.3 Evidence (Nachweise)</h3>
|
||||||
|
<p>
|
||||||
|
Ein Control allein genuegt nicht -- man muss auch <strong>nachweisen</strong>, dass er
|
||||||
|
umgesetzt wurde. Das System verwaltet verschiedene Nachweis-Typen:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Zertifikate:</strong> ISO 27001-Zertifikat, SOC2-Report</li>
|
||||||
|
<li><strong>Richtlinien:</strong> Interne Datenschutzrichtlinie, Passwort-Policy</li>
|
||||||
|
<li><strong>Audit-Berichte:</strong> Ergebnisse interner oder externer Pruefungen</li>
|
||||||
|
<li><strong>Screenshots / Konfigurationen:</strong> Nachweis technischer Umsetzung</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Jeder Nachweis hat ein <strong>Ablaufdatum</strong>. Das System warnt automatisch,
|
||||||
|
wenn Nachweise bald ablaufen (z.B. ein ISO-Zertifikat, das in 3 Monaten erneuert werden muss).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>6.4 Risikobewertung</h3>
|
||||||
|
<p>
|
||||||
|
Risiken werden in einer <strong>5x5-Risikomatrix</strong> dargestellt. Die beiden Achsen sind:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Eintrittswahrscheinlichkeit:</strong> Wie wahrscheinlich ist es, dass das Risiko eintritt?</li>
|
||||||
|
<li><strong>Auswirkung:</strong> Wie schwerwiegend waeren die Folgen?</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Aus der Kombination ergibt sich die Risikostufe: <em>Minimal</em>, <em>Low</em>,
|
||||||
|
<em>Medium</em>, <em>High</em> oder <em>Critical</em>. Fuer jedes identifizierte Risiko
|
||||||
|
wird dokumentiert, welche Controls es abmildern und wer dafuer verantwortlich ist.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ============================================================ */}
|
||||||
|
{/* 7. OBLIGATIONS FRAMEWORK */}
|
||||||
|
{/* ============================================================ */}
|
||||||
|
<h2 id="obligations">7. Pflichten-Ableitung: Welche Gesetze gelten fuer mich?</h2>
|
||||||
|
<p>
|
||||||
|
Nicht jedes Gesetz gilt fuer jede Organisation. Das <strong>Obligations Framework</strong>
|
||||||
|
ermittelt automatisch, welche konkreten Pflichten sich aus der Situation einer Organisation
|
||||||
|
ergeben. Dafuer werden “Fakten” ueber die Organisation gesammelt und gegen die
|
||||||
|
Anwendbarkeitsbedingungen der einzelnen Gesetze geprueft.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Beispiel: NIS2-Anwendbarkeit</h3>
|
||||||
|
<CodeBlock language="text" filename="Entscheidungsbaum: Gilt NIS2 fuer mein Unternehmen?">
|
||||||
|
{`Ist Ihr Unternehmen in einem der NIS2-Sektoren taetig?
|
||||||
|
(Energie, Transport, Banken, Gesundheit, Wasser, Digitale Infrastruktur, ...)
|
||||||
|
│
|
||||||
|
├── Nein → NIS2 gilt NICHT fuer Sie
|
||||||
|
│
|
||||||
|
└── Ja → Wie gross ist Ihr Unternehmen?
|
||||||
|
│
|
||||||
|
├── >= 250 Mitarbeiter ODER >= 50 Mio. EUR Umsatz
|
||||||
|
│ → ESSENTIAL ENTITY (wesentliche Einrichtung)
|
||||||
|
│ → Volle NIS2-Pflichten, strenge Aufsicht
|
||||||
|
│ → Bussgelder bis 10 Mio. EUR oder 2% Jahresumsatz
|
||||||
|
│
|
||||||
|
├── >= 50 Mitarbeiter ODER >= 10 Mio. EUR Umsatz
|
||||||
|
│ → IMPORTANT ENTITY (wichtige Einrichtung)
|
||||||
|
│ → NIS2-Pflichten, reaktive Aufsicht
|
||||||
|
│ → Bussgelder bis 7 Mio. EUR oder 1,4% Jahresumsatz
|
||||||
|
│
|
||||||
|
└── Kleiner → NIS2 gilt grundsaetzlich NICHT
|
||||||
|
(Ausnahmen fuer bestimmte Sektoren moeglich)`}
|
||||||
|
</CodeBlock>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Aehnliche Entscheidungsbaeume existieren fuer DSGVO (Verarbeitung personenbezogener Daten?),
|
||||||
|
AI Act (KI-System im Einsatz? Welche Risikokategorie?) und alle anderen Regelwerke.
|
||||||
|
Das System leitet daraus konkrete Pflichten ab -- z.B. “Meldepflicht bei
|
||||||
|
Sicherheitsvorfaellen innerhalb von 72 Stunden” oder “Ernennung eines
|
||||||
|
Datenschutzbeauftragten”.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ============================================================ */}
|
||||||
|
{/* 8. DSGVO-MODULE */}
|
||||||
|
{/* ============================================================ */}
|
||||||
|
<h2 id="dsgvo-module">8. DSGVO-Compliance-Module im Detail</h2>
|
||||||
|
<p>
|
||||||
|
Fuer die Einhaltung der DSGVO bietet der Compliance Hub spezialisierte Module:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>8.1 Consent Management (Einwilligungsverwaltung)</h3>
|
||||||
|
<p>
|
||||||
|
Verwaltet die Einwilligung von Nutzern gemaess Art. 6/7 DSGVO. Jede Einwilligung wird
|
||||||
|
protokolliert: wer hat wann, auf welchem Kanal, fuer welchen Zweck zugestimmt (oder
|
||||||
|
abgelehnt)? Einwilligungen koennen jederzeit widerrufen werden, der Widerruf wird ebenfalls
|
||||||
|
dokumentiert.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Zwecke:</strong> Essential (funktionsnotwendig), Functional, Analytics, Marketing,
|
||||||
|
Personalization, Third-Party.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>8.2 DSR Management (Betroffenenrechte)</h3>
|
||||||
|
<p>
|
||||||
|
Verwaltet Antraege betroffener Personen nach Art. 15-21 DSGVO: Auskunft, Berichtigung,
|
||||||
|
Loeschung, Datenportabilitaet, Einschraenkung und Widerspruch. Das System ueberwacht die
|
||||||
|
<strong> 30-Tage-Frist</strong> (Art. 12) und eskaliert automatisch, wenn Fristen drohen
|
||||||
|
zu verstreichen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>8.3 VVT (Verzeichnis von Verarbeitungstaetigkeiten)</h3>
|
||||||
|
<p>
|
||||||
|
Dokumentiert alle Datenverarbeitungen gemaess Art. 30 DSGVO: Welche Daten werden fuer
|
||||||
|
welchen Zweck, auf welcher Rechtsgrundlage, wie lange und von wem verarbeitet? Jede
|
||||||
|
Verarbeitungstaetigkeit wird mit ihren Datenkategorien, Empfaengern und
|
||||||
|
Loeschfristen erfasst.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>8.4 DSFA (Datenschutz-Folgenabschaetzung)</h3>
|
||||||
|
<p>
|
||||||
|
Wenn eine Datenverarbeitung voraussichtlich ein hohes Risiko fuer die Rechte natuerlicher
|
||||||
|
Personen mit sich bringt, ist eine DSFA nach Art. 35 DSGVO Pflicht. Das System unterstuetzt
|
||||||
|
den Prozess: Risiken identifizieren, bewerten, Gegenmassnahmen definieren und das Ergebnis
|
||||||
|
dokumentieren.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>8.5 TOM (Technisch-Organisatorische Massnahmen)</h3>
|
||||||
|
<p>
|
||||||
|
Dokumentiert die Schutzmassnahmen nach Art. 32 DSGVO. Fuer jede Massnahme wird erfasst:
|
||||||
|
Kategorie (z.B. Verschluesselung, Zugriffskontrolle), Status (implementiert / in
|
||||||
|
Bearbeitung / geplant), Verantwortlicher und Nachweise.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>8.6 Loeschkonzept</h3>
|
||||||
|
<p>
|
||||||
|
Verwaltet Aufbewahrungsfristen und automatische Loeschung gemaess Art. 5/17 DSGVO.
|
||||||
|
Fuer jede Datenkategorie wird definiert: wie lange darf sie gespeichert werden, wann muss
|
||||||
|
sie geloescht werden und wie (z.B. Ueberschreiben, Schluesselloeschung bei verschluesselten
|
||||||
|
Daten).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ============================================================ */}
|
||||||
|
{/* 9. MULTI-TENANCY & ZUGRIFFSKONTROLLE */}
|
||||||
|
{/* ============================================================ */}
|
||||||
|
<h2 id="multi-tenancy">9. Multi-Tenancy und Zugriffskontrolle</h2>
|
||||||
|
<p>
|
||||||
|
Das System ist <strong>mandantenfaehig</strong> (Multi-Tenant): Mehrere Organisationen
|
||||||
|
koennen es gleichzeitig nutzen, ohne dass sie gegenseitig auf ihre Daten zugreifen koennen.
|
||||||
|
Jede Anfrage enthaelt eine Tenant-ID, und die Datenbank-Abfragen filtern automatisch nach
|
||||||
|
dieser ID.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>9.1 Rollenbasierte Zugriffskontrolle (RBAC)</h3>
|
||||||
|
<p>
|
||||||
|
Innerhalb eines Mandanten gibt es verschiedene Rollen mit unterschiedlichen Berechtigungen:
|
||||||
|
</p>
|
||||||
|
<div className="not-prose my-4 overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Rolle</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Darf</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Mitarbeiter</td><td className="px-4 py-3">Anwendungsfaelle einreichen, eigene Bewertungen einsehen</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Teamleiter</td><td className="px-4 py-3">E1-Eskalationen pruefen, Team-Assessments einsehen</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">DSB (Datenschutzbeauftragter)</td><td className="px-4 py-3">E2/E3-Eskalationen pruefen, alle Assessments einsehen, Policies aendern</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Rechtsabteilung</td><td className="px-4 py-3">E3-Eskalationen pruefen, Grundsatzentscheidungen</td></tr>
|
||||||
|
<tr><td className="px-4 py-3 font-medium">Administrator</td><td className="px-4 py-3">System konfigurieren, Nutzer verwalten, LLM-Policies festlegen</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>9.2 PII-Erkennung und -Schutz</h3>
|
||||||
|
<p>
|
||||||
|
Bevor Texte an ein Sprachmodell gesendet werden, durchlaufen sie eine automatische
|
||||||
|
<strong> PII-Erkennung</strong> (Personally Identifiable Information). Das System erkennt
|
||||||
|
ueber 20 Arten personenbezogener Daten:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>E-Mail-Adressen, Telefonnummern, Postanschriften</li>
|
||||||
|
<li>Sozialversicherungsnummern, Kreditkartennummern</li>
|
||||||
|
<li>Personennamen, IP-Adressen</li>
|
||||||
|
<li>und weitere...</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Je nach Konfiguration werden erkannte PII-Daten <strong>geschwuerzt</strong> (durch
|
||||||
|
Platzhalter ersetzt), <strong>maskiert</strong> (nur Anfang/Ende sichtbar) oder nur im
|
||||||
|
Audit-Log <strong>markiert</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ============================================================ */}
|
||||||
|
{/* 10. LLM-NUTZUNG */}
|
||||||
|
{/* ============================================================ */}
|
||||||
|
<h2 id="llm-nutzung">10. Wie das System KI nutzt (und wie nicht)</h2>
|
||||||
|
<p>
|
||||||
|
Der Compliance Hub setzt kuenstliche Intelligenz gezielt und kontrolliert ein. Es gibt
|
||||||
|
eine klare Trennung zwischen dem, was die KI tut, und dem, was sie nicht tun darf:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="not-prose my-6 overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Aufgabe</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Entschieden von</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500">Rolle der KI</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
<tr><td className="px-4 py-3">Machbarkeit (YES/CONDITIONAL/NO)</td><td className="px-4 py-3 font-medium">Deterministische Regeln</td><td className="px-4 py-3 text-gray-400">Keine</td></tr>
|
||||||
|
<tr><td className="px-4 py-3">Risikoscore berechnen</td><td className="px-4 py-3 font-medium">Regelbasierte Berechnung</td><td className="px-4 py-3 text-gray-400">Keine</td></tr>
|
||||||
|
<tr><td className="px-4 py-3">Eskalation ausloesen</td><td className="px-4 py-3 font-medium">Schwellenwerte + Regellogik</td><td className="px-4 py-3 text-gray-400">Keine</td></tr>
|
||||||
|
<tr><td className="px-4 py-3">Controls zuordnen</td><td className="px-4 py-3 font-medium">Regel-zu-Control-Mapping</td><td className="px-4 py-3 text-gray-400">Keine</td></tr>
|
||||||
|
<tr className="bg-blue-50"><td className="px-4 py-3">Ergebnis erklaeren</td><td className="px-4 py-3 text-gray-400">--</td><td className="px-4 py-3 font-medium text-blue-800">LLM + RAG-Kontext</td></tr>
|
||||||
|
<tr className="bg-blue-50"><td className="px-4 py-3">Verbesserungsvorschlaege</td><td className="px-4 py-3 text-gray-400">--</td><td className="px-4 py-3 font-medium text-blue-800">LLM</td></tr>
|
||||||
|
<tr className="bg-blue-50"><td className="px-4 py-3">Rechtsfragen beantworten</td><td className="px-4 py-3 text-gray-400">--</td><td className="px-4 py-3 font-medium text-blue-800">LLM + RAG (Rechtskorpus)</td></tr>
|
||||||
|
<tr className="bg-blue-50"><td className="px-4 py-3">Dokumente generieren (DSFA, TOM, VVT)</td><td className="px-4 py-3 text-gray-400">--</td><td className="px-4 py-3 font-medium text-blue-800">LLM + Vorlagen</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>LLM-Provider und Fallback</h3>
|
||||||
|
<p>
|
||||||
|
Das System unterstuetzt mehrere KI-Anbieter mit automatischem Fallback:
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Primaer: Ollama (lokal)</strong> -- Qwen 2.5 32B bzw. Mistral, laeuft direkt auf dem Server. Keine Daten verlassen das lokale Netzwerk.</li>
|
||||||
|
<li><strong>Fallback: Anthropic Claude</strong> -- Wird nur aktiviert, wenn das lokale Modell nicht verfuegbar ist.</li>
|
||||||
|
</ol>
|
||||||
|
<p>
|
||||||
|
Jeder LLM-Aufruf wird im Audit-Trail protokolliert: Prompt-Hash (SHA-256), verwendetes
|
||||||
|
Modell, Antwortzeit und ob PII erkannt wurde.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ============================================================ */}
|
||||||
|
{/* 11. AUDIT-TRAIL */}
|
||||||
|
{/* ============================================================ */}
|
||||||
|
<h2 id="audit-trail">11. Audit-Trail: Alles wird protokolliert</h2>
|
||||||
|
<p>
|
||||||
|
Saemtliche Aktionen im System werden revisionssicher protokolliert:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Jede Compliance-Bewertung mit allen Ein- und Ausgaben</li>
|
||||||
|
<li>Jede Eskalationsentscheidung mit Begruendung</li>
|
||||||
|
<li>Jeder LLM-Aufruf (wer hat was wann gefragt, welches Modell wurde verwendet)</li>
|
||||||
|
<li>Jede Aenderung an Controls, Evidence und Policies</li>
|
||||||
|
<li>Jeder Login und Daten-Export</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Der Audit-Trail kann als <strong>PDF, CSV oder JSON</strong> exportiert werden und dient als
|
||||||
|
Nachweis gegenueber Aufsichtsbehoerden, Wirtschaftspruefern und internen Revisoren.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<InfoBox type="info" title="Datenschutz des Audit-Trails">
|
||||||
|
Der Use-Case-Text (die Beschreibung des Anwendungsfalls) wird
|
||||||
|
<strong> nur mit Einwilligung des Nutzers</strong> gespeichert. Standardmaessig wird nur
|
||||||
|
ein SHA-256-Hash des Textes gespeichert -- damit kann nachgewiesen werden, <em>dass</em>
|
||||||
|
ein bestimmter Text bewertet wurde, ohne den Text selbst preiszugeben.
|
||||||
|
</InfoBox>
|
||||||
|
|
||||||
|
{/* ============================================================ */}
|
||||||
|
{/* 12. SECURITY SCANNER */}
|
||||||
|
{/* ============================================================ */}
|
||||||
|
<h2 id="security">12. Security Scanner: Technische Sicherheitspruefung</h2>
|
||||||
|
<p>
|
||||||
|
Ergaenzend zur rechtlichen Compliance prueft der Security Scanner die
|
||||||
|
<strong> technische Sicherheit</strong>:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Container-Scanning (Trivy):</strong> Prueft Docker-Images auf bekannte Schwachstellen (CVEs)</li>
|
||||||
|
<li><strong>Statische Code-Analyse (Semgrep):</strong> Sucht im Quellcode nach Sicherheitsluecken (SQL Injection, XSS, etc.)</li>
|
||||||
|
<li><strong>Secret Detection (Gitleaks):</strong> Findet versehentlich eingecheckte Passwoerter, API-Keys und Tokens</li>
|
||||||
|
<li><strong>SBOM-Generierung:</strong> Erstellt eine Software Bill of Materials -- eine vollstaendige Liste aller verwendeten Bibliotheken und deren Lizenzen</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Gefundene Schwachstellen werden nach Schweregrad (Critical, High, Medium, Low) klassifiziert
|
||||||
|
und koennen direkt im System nachverfolgt und behoben werden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ============================================================ */}
|
||||||
|
{/* 13. ZUSAMMENFASSUNG */}
|
||||||
|
{/* ============================================================ */}
|
||||||
|
<h2 id="zusammenfassung">13. Zusammenfassung: Der komplette Datenfluss</h2>
|
||||||
|
<p>
|
||||||
|
Hier ist der gesamte Prozess von Anfang bis Ende:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CodeBlock language="text" filename="Der komplette Compliance-Workflow">
|
||||||
|
{`SCHRITT 1: FAKTEN SAMMELN
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
Nutzer fuellt Fragebogen aus:
|
||||||
|
→ Welche Daten? Welcher Zweck? Welche Branche? Wo gehostet?
|
||||||
|
|
||||||
|
SCHRITT 2: ANWENDBARKEIT PRUEFEN
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
Obligations Framework ermittelt:
|
||||||
|
→ DSGVO betroffen? → Ja (personenbezogene Daten)
|
||||||
|
→ AI Act betroffen? → Ja (KI-System)
|
||||||
|
→ NIS2 betroffen? → Nein (< 50 Mitarbeiter, kein KRITIS-Sektor)
|
||||||
|
|
||||||
|
SCHRITT 3: REGELN PRUEFEN
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
Policy Engine wertet 45+ Regeln aus:
|
||||||
|
→ R-001 (WARN): Personenbezogene Daten +10 Risiko
|
||||||
|
→ R-020 (INFO): Assistenzsystem +0 Risiko
|
||||||
|
→ R-060 (WARN): KI-Transparenz fehlt +15 Risiko
|
||||||
|
→ ...
|
||||||
|
→ Gesamt-Risikoscore: 35/100 (LOW)
|
||||||
|
→ Machbarkeit: CONDITIONAL
|
||||||
|
|
||||||
|
SCHRITT 4: CONTROLS ZUORDNEN
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
Jede ausgeloeste Regel triggert Controls:
|
||||||
|
→ C_EXPLICIT_CONSENT: Einwilligung einholen
|
||||||
|
→ C_TRANSPARENCY: KI-Nutzung offenlegen
|
||||||
|
→ C_DATA_MINIMIZATION: Datenminimierung
|
||||||
|
|
||||||
|
SCHRITT 5: ESKALATION (bei Bedarf)
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
Score 35 → Stufe E1 → Teamleiter wird benachrichtigt
|
||||||
|
→ SLA: 24 Stunden fuer Pruefung
|
||||||
|
→ Entscheidung: Freigabe mit Auflagen
|
||||||
|
|
||||||
|
SCHRITT 6: ERKLAERUNG GENERIEREN
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
LLM + RAG erstellen verstaendliche Erklaerung:
|
||||||
|
→ Suche relevante Gesetzesartikel (Qdrant)
|
||||||
|
→ Generiere Erklaerungstext (Qwen 2.5)
|
||||||
|
→ Fuege Zitate und Quellen hinzu
|
||||||
|
|
||||||
|
SCHRITT 7: DOKUMENTATION
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
System erzeugt erforderliche Dokumente:
|
||||||
|
→ DSFA (falls empfohlen)
|
||||||
|
→ TOM-Dokumentation
|
||||||
|
→ VVT-Eintrag
|
||||||
|
→ Compliance-Report (PDF/ZIP/JSON)
|
||||||
|
|
||||||
|
SCHRITT 8: MONITORING
|
||||||
|
━━━━━━━━━━━━━━━━━━━━
|
||||||
|
Laufende Ueberwachung:
|
||||||
|
→ Controls werden regelmaessig geprueft
|
||||||
|
→ Nachweise werden auf Ablauf ueberwacht
|
||||||
|
→ Gesetzesaenderungen fliessen in den Corpus ein`}
|
||||||
|
</CodeBlock>
|
||||||
|
|
||||||
|
<InfoBox type="success" title="Das Wichtigste in einem Satz">
|
||||||
|
Der Compliance Hub nimmt die Beschreibung eines KI-Vorhabens entgegen, prueft es gegen
|
||||||
|
ueber 45 deterministische Regeln und 400+ Gesetzesartikel, berechnet ein Risiko, ordnet
|
||||||
|
Massnahmen zu, eskaliert bei Bedarf an menschliche Pruefer und dokumentiert alles
|
||||||
|
revisionssicher -- wobei die KI nur fuer Erklaerungen und Zusammenfassungen eingesetzt wird,
|
||||||
|
niemals fuer die eigentliche Compliance-Entscheidung.
|
||||||
|
</InfoBox>
|
||||||
|
</DevPortalLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { Book, Code, FileText, HelpCircle, Zap, Terminal, Database, Shield, ChevronRight, Clock } from 'lucide-react'
|
import { Book, Code, FileText, HelpCircle, Zap, Terminal, Database, Shield, ChevronRight, Clock, BookOpen } from 'lucide-react'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
title: string
|
title: string
|
||||||
@@ -66,6 +66,14 @@ const navigation: NavItem[] = [
|
|||||||
{ title: 'Phase 2: Dokumentation', href: '/guides/phase2' },
|
{ title: 'Phase 2: Dokumentation', href: '/guides/phase2' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Systemdokumentation',
|
||||||
|
href: '/development/docs',
|
||||||
|
icon: <BookOpen className="w-4 h-4" />,
|
||||||
|
items: [
|
||||||
|
{ title: 'Compliance Service', href: '/development/docs' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Changelog',
|
title: 'Changelog',
|
||||||
href: '/changelog',
|
href: '/changelog',
|
||||||
|
|||||||
Reference in New Issue
Block a user