Merge branch 'main' of http://localhost:3003/pilotadmin/breakpilot-pwa
ci/woodpecker/push/integration Pipeline failed
ci/woodpecker/push/main Pipeline failed
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Tests / Go Tests (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled

# Conflicts:
#	agent-core/soul/investor-agent.soul.md
This commit is contained in:
BreakPilot Dev
2026-02-15 10:20:46 +01:00
112 changed files with 5852 additions and 113472 deletions
+6
View File
@@ -184,6 +184,12 @@ docs/za-download-3/
*.docx
*.xlsx
*.pptx
*.numbers
# ============================================
# MkDocs Build Output
# ============================================
docs-site/
# ============================================
# Entfernte Projekte (nicht mehr aktiv)
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
# 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"]
+296
View File
@@ -0,0 +1,296 @@
# 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' })
});
```
+143
View File
@@ -0,0 +1,143 @@
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)
}
}
+49
View File
@@ -0,0 +1,49 @@
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
)
+111
View File
@@ -0,0 +1,111 @@
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=
+157
View File
@@ -0,0 +1,157 @@
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
}
@@ -0,0 +1,260 @@
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
}
@@ -0,0 +1,427 @@
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,
})
}
@@ -0,0 +1,612 @@
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)
}
}
@@ -0,0 +1,205 @@
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
}
@@ -0,0 +1,433 @@
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)
}
}
}
@@ -0,0 +1,288 @@
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")
}
+372
View File
@@ -0,0 +1,372 @@
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
}
@@ -0,0 +1,319 @@
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)
}
}
@@ -0,0 +1,232 @@
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
}
@@ -0,0 +1,317 @@
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
}
@@ -0,0 +1,315 @@
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
}
@@ -0,0 +1,326 @@
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)
}
}
}
@@ -0,0 +1,352 @@
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
}
@@ -0,0 +1,397 @@
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)
}
})
}
}
@@ -0,0 +1,194 @@
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)
}
-1899
View File
File diff suppressed because it is too large Load Diff
-58
View File
@@ -1,58 +0,0 @@
# ============================================
# Breakpilot Dokumentation - MkDocs Build
# Multi-stage build fuer minimale Image-Groesse
# ============================================
# Stage 1: Build MkDocs Site
FROM python:3.11-slim AS builder
WORKDIR /docs
# Install MkDocs with Material theme and plugins
RUN pip install --no-cache-dir \
mkdocs==1.6.1 \
mkdocs-material==9.5.47 \
pymdown-extensions==10.12
# Copy configuration and source files
COPY mkdocs.yml /docs/
COPY docs-src/ /docs/docs-src/
# Build static site
RUN mkdocs build
# Stage 2: Serve with Nginx
FROM nginx:alpine
# Copy built site from builder stage
COPY --from=builder /docs/docs-site /usr/share/nginx/html
# Custom nginx config for SPA routing
RUN echo 'server { \
listen 80; \
server_name localhost; \
root /usr/share/nginx/html; \
index index.html; \
\
location / { \
try_files $uri $uri/ /index.html; \
} \
\
# Enable gzip compression \
gzip on; \
gzip_types text/plain text/css application/json application/javascript text/xml application/xml; \
gzip_min_length 1000; \
\
# Cache static assets \
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { \
expires 1y; \
add_header Cache-Control "public, immutable"; \
} \
}' > /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-18
View File
@@ -1,18 +0,0 @@
/*!
* Lunr languages, `Danish` language
* https://github.com/MihaiValentin/lunr-languages
*
* Copyright 2014, Mihai Valentin
* http://www.mozilla.org/MPL/
*/
/*!
* based on
* Snowball JavaScript Library v0.3
* http://code.google.com/p/urim/
* http://snowball.tartarus.org/
*
* Copyright 2010, Oleg Mazko
* http://www.mozilla.org/MPL/
*/
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.da=function(){this.pipeline.reset(),this.pipeline.add(e.da.trimmer,e.da.stopWordFilter,e.da.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.da.stemmer))},e.da.wordCharacters="A-Za-zªºÀ-ÖØ-öø-ʸˠ-ˤᴀ-ᴥᴬ-ᵜᵢ-ᵥᵫ-ᵷᵹ-ᶾḀ-ỿⁱⁿₐ-ₜKÅℲⅎⅠ-ↈⱠ-ⱿꜢ-ꞇꞋ-ꞭꞰ-ꞷꟷ-ꟿꬰ-ꭚꭜ-ꭤff-stA-Za-z",e.da.trimmer=e.trimmerSupport.generateTrimmer(e.da.wordCharacters),e.Pipeline.registerFunction(e.da.trimmer,"trimmer-da"),e.da.stemmer=function(){var r=e.stemmerSupport.Among,i=e.stemmerSupport.SnowballProgram,n=new function(){function e(){var e,r=f.cursor+3;if(d=f.limit,0<=r&&r<=f.limit){for(a=r;;){if(e=f.cursor,f.in_grouping(w,97,248)){f.cursor=e;break}if(f.cursor=e,e>=f.limit)return;f.cursor++}for(;!f.out_grouping(w,97,248);){if(f.cursor>=f.limit)return;f.cursor++}d=f.cursor,d<a&&(d=a)}}function n(){var e,r;if(f.cursor>=d&&(r=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,e=f.find_among_b(c,32),f.limit_backward=r,e))switch(f.bra=f.cursor,e){case 1:f.slice_del();break;case 2:f.in_grouping_b(p,97,229)&&f.slice_del()}}function t(){var e,r=f.limit-f.cursor;f.cursor>=d&&(e=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,f.find_among_b(l,4)?(f.bra=f.cursor,f.limit_backward=e,f.cursor=f.limit-r,f.cursor>f.limit_backward&&(f.cursor--,f.bra=f.cursor,f.slice_del())):f.limit_backward=e)}function s(){var e,r,i,n=f.limit-f.cursor;if(f.ket=f.cursor,f.eq_s_b(2,"st")&&(f.bra=f.cursor,f.eq_s_b(2,"ig")&&f.slice_del()),f.cursor=f.limit-n,f.cursor>=d&&(r=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,e=f.find_among_b(m,5),f.limit_backward=r,e))switch(f.bra=f.cursor,e){case 1:f.slice_del(),i=f.limit-f.cursor,t(),f.cursor=f.limit-i;break;case 2:f.slice_from("løs")}}function o(){var e;f.cursor>=d&&(e=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,f.out_grouping_b(w,97,248)?(f.bra=f.cursor,u=f.slice_to(u),f.limit_backward=e,f.eq_v_b(u)&&f.slice_del()):f.limit_backward=e)}var a,d,u,c=[new r("hed",-1,1),new r("ethed",0,1),new r("ered",-1,1),new r("e",-1,1),new r("erede",3,1),new r("ende",3,1),new r("erende",5,1),new r("ene",3,1),new r("erne",3,1),new r("ere",3,1),new r("en",-1,1),new r("heden",10,1),new r("eren",10,1),new r("er",-1,1),new r("heder",13,1),new r("erer",13,1),new r("s",-1,2),new r("heds",16,1),new r("es",16,1),new r("endes",18,1),new r("erendes",19,1),new r("enes",18,1),new r("ernes",18,1),new r("eres",18,1),new r("ens",16,1),new r("hedens",24,1),new r("erens",24,1),new r("ers",16,1),new r("ets",16,1),new r("erets",28,1),new r("et",-1,1),new r("eret",30,1)],l=[new r("gd",-1,-1),new r("dt",-1,-1),new r("gt",-1,-1),new r("kt",-1,-1)],m=[new r("ig",-1,1),new r("lig",0,1),new r("elig",1,1),new r("els",-1,1),new r("løst",-1,2)],w=[17,65,16,1,0,0,0,0,0,0,0,0,0,0,0,0,48,0,128],p=[239,254,42,3,0,0,0,0,0,0,0,0,0,0,0,0,16],f=new i;this.setCurrent=function(e){f.setCurrent(e)},this.getCurrent=function(){return f.getCurrent()},this.stem=function(){var r=f.cursor;return e(),f.limit_backward=r,f.cursor=f.limit,n(),f.cursor=f.limit,t(),f.cursor=f.limit,s(),f.cursor=f.limit,o(),!0}};return function(e){return"function"==typeof e.update?e.update(function(e){return n.setCurrent(e),n.stem(),n.getCurrent()}):(n.setCurrent(e),n.stem(),n.getCurrent())}}(),e.Pipeline.registerFunction(e.da.stemmer,"stemmer-da"),e.da.stopWordFilter=e.generateStopWordFilter("ad af alle alt anden at blev blive bliver da de dem den denne der deres det dette dig din disse dog du efter eller en end er et for fra ham han hans har havde have hende hendes her hos hun hvad hvis hvor i ikke ind jeg jer jo kunne man mange med meget men mig min mine mit mod ned noget nogle nu når og også om op os over på selv sig sin sine sit skal skulle som sådan thi til ud under var vi vil ville vor være været".split(" ")),e.Pipeline.registerFunction(e.da.stopWordFilter,"stopWordFilter-da")}});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.hi=function(){this.pipeline.reset(),this.pipeline.add(e.hi.trimmer,e.hi.stopWordFilter,e.hi.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.hi.stemmer))},e.hi.wordCharacters="ऀ-ःऄ-एऐ-टठ-यर-िी-ॏॐ-य़ॠ-९॰-ॿa-zA-Z-zA-0-9-",e.hi.trimmer=e.trimmerSupport.generateTrimmer(e.hi.wordCharacters),e.Pipeline.registerFunction(e.hi.trimmer,"trimmer-hi"),e.hi.stopWordFilter=e.generateStopWordFilter("अत अपना अपनी अपने अभी अंदर आदि आप इत्यादि इन इनका इन्हीं इन्हें इन्हों इस इसका इसकी इसके इसमें इसी इसे उन उनका उनकी उनके उनको उन्हीं उन्हें उन्हों उस उसके उसी उसे एक एवं एस ऐसे और कई कर करता करते करना करने करें कहते कहा का काफ़ी कि कितना किन्हें किन्हों किया किर किस किसी किसे की कुछ कुल के को कोई कौन कौनसा गया घर जब जहाँ जा जितना जिन जिन्हें जिन्हों जिस जिसे जीधर जैसा जैसे जो तक तब तरह तिन तिन्हें तिन्हों तिस तिसे तो था थी थे दबारा दिया दुसरा दूसरे दो द्वारा न नके नहीं ना निहायत नीचे ने पर पहले पूरा पे फिर बनी बही बहुत बाद बाला बिलकुल भी भीतर मगर मानो मे में यदि यह यहाँ यही या यिह ये रखें रहा रहे ऱ्वासा लिए लिये लेकिन व वग़ैरह वर्ग वह वहाँ वहीं वाले वुह वे वो सकता सकते सबसे सभी साथ साबुत साभ सारा से सो संग ही हुआ हुई हुए है हैं हो होता होती होते होना होने".split(" ")),e.hi.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var r=e.wordcut;r.init(),e.hi.tokenizer=function(i){if(!arguments.length||null==i||void 0==i)return[];if(Array.isArray(i))return i.map(function(r){return isLunr2?new e.Token(r.toLowerCase()):r.toLowerCase()});var t=i.toString().toLowerCase().replace(/^\s+/,"");return r.cut(t).split("|")},e.Pipeline.registerFunction(e.hi.stemmer,"stemmer-hi"),e.Pipeline.registerFunction(e.hi.stopWordFilter,"stopWordFilter-hi")}});
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.hy=function(){this.pipeline.reset(),this.pipeline.add(e.hy.trimmer,e.hy.stopWordFilter)},e.hy.wordCharacters="[A-Za-z԰-֏ff-ﭏ]",e.hy.trimmer=e.trimmerSupport.generateTrimmer(e.hy.wordCharacters),e.Pipeline.registerFunction(e.hy.trimmer,"trimmer-hy"),e.hy.stopWordFilter=e.generateStopWordFilter("դու և եք էիր էիք հետո նաև նրանք որը վրա է որ պիտի են այս մեջ ն իր ու ի այդ որոնք այն կամ էր մի ես համար այլ իսկ էին ենք հետ ին թ էինք մենք նրա նա դուք եմ էի ըստ որպես ում".split(" ")),e.Pipeline.registerFunction(e.hy.stopWordFilter,"stopWordFilter-hy"),e.hy.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}(),e.Pipeline.registerFunction(e.hy.stemmer,"stemmer-hy")}});
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");var r="2"==e.version[0];e.ja=function(){this.pipeline.reset(),this.pipeline.add(e.ja.trimmer,e.ja.stopWordFilter,e.ja.stemmer),r?this.tokenizer=e.ja.tokenizer:(e.tokenizer&&(e.tokenizer=e.ja.tokenizer),this.tokenizerFn&&(this.tokenizerFn=e.ja.tokenizer))};var t=new e.TinySegmenter;e.ja.tokenizer=function(i){var n,o,s,p,a,u,m,l,c,f;if(!arguments.length||null==i||void 0==i)return[];if(Array.isArray(i))return i.map(function(t){return r?new e.Token(t.toLowerCase()):t.toLowerCase()});for(o=i.toString().toLowerCase().replace(/^\s+/,""),n=o.length-1;n>=0;n--)if(/\S/.test(o.charAt(n))){o=o.substring(0,n+1);break}for(a=[],s=o.length,c=0,l=0;c<=s;c++)if(u=o.charAt(c),m=c-l,u.match(/\s/)||c==s){if(m>0)for(p=t.segment(o.slice(l,c)).filter(function(e){return!!e}),f=l,n=0;n<p.length;n++)r?a.push(new e.Token(p[n],{position:[f,p[n].length],index:a.length})):a.push(p[n]),f+=p[n].length;l=c+1}return a},e.ja.stemmer=function(){return function(e){return e}}(),e.Pipeline.registerFunction(e.ja.stemmer,"stemmer-ja"),e.ja.wordCharacters="一二三四五六七八九十百千万億兆一-龠々〆ヵヶぁ-んァ-ヴーア-ン゙a-zA-Z-zA-0-9-",e.ja.trimmer=e.trimmerSupport.generateTrimmer(e.ja.wordCharacters),e.Pipeline.registerFunction(e.ja.trimmer,"trimmer-ja"),e.ja.stopWordFilter=e.generateStopWordFilter("これ それ あれ この その あの ここ そこ あそこ こちら どこ だれ なに なん 何 私 貴方 貴方方 我々 私達 あの人 あのかた 彼女 彼 です あります おります います は が の に を で え から まで より も どの と し それで しかし".split(" ")),e.Pipeline.registerFunction(e.ja.stopWordFilter,"stopWordFilter-ja"),e.jp=e.ja,e.Pipeline.registerFunction(e.jp.stemmer,"stemmer-jp"),e.Pipeline.registerFunction(e.jp.trimmer,"trimmer-jp"),e.Pipeline.registerFunction(e.jp.stopWordFilter,"stopWordFilter-jp")}});
-1
View File
@@ -1 +0,0 @@
module.exports=require("./lunr.ja");
-1
View File
@@ -1 +0,0 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.kn=function(){this.pipeline.reset(),this.pipeline.add(e.kn.trimmer,e.kn.stopWordFilter,e.kn.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.kn.stemmer))},e.kn.wordCharacters="ಀ-಄ಅ-ಔಕ-ಹಾ-ೌ಼-ಽೕ-ೖೝ-ೞೠ-ೡೢ-ೣ೤೥೦-೯ೱ-ೳ",e.kn.trimmer=e.trimmerSupport.generateTrimmer(e.kn.wordCharacters),e.Pipeline.registerFunction(e.kn.trimmer,"trimmer-kn"),e.kn.stopWordFilter=e.generateStopWordFilter("ಮತ್ತು ಈ ಒಂದು ರಲ್ಲಿ ಹಾಗೂ ಎಂದು ಅಥವಾ ಇದು ರ ಅವರು ಎಂಬ ಮೇಲೆ ಅವರ ತನ್ನ ಆದರೆ ತಮ್ಮ ನಂತರ ಮೂಲಕ ಹೆಚ್ಚು ನ ಆ ಕೆಲವು ಅನೇಕ ಎರಡು ಹಾಗು ಪ್ರಮುಖ ಇದನ್ನು ಇದರ ಸುಮಾರು ಅದರ ಅದು ಮೊದಲ ಬಗ್ಗೆ ನಲ್ಲಿ ರಂದು ಇತರ ಅತ್ಯಂತ ಹೆಚ್ಚಿನ ಸಹ ಸಾಮಾನ್ಯವಾಗಿ ನೇ ಹಲವಾರು ಹೊಸ ದಿ ಕಡಿಮೆ ಯಾವುದೇ ಹೊಂದಿದೆ ದೊಡ್ಡ ಅನ್ನು ಇವರು ಪ್ರಕಾರ ಇದೆ ಮಾತ್ರ ಕೂಡ ಇಲ್ಲಿ ಎಲ್ಲಾ ವಿವಿಧ ಅದನ್ನು ಹಲವು ರಿಂದ ಕೇವಲ ದ ದಕ್ಷಿಣ ಗೆ ಅವನ ಅತಿ ನೆಯ ಬಹಳ ಕೆಲಸ ಎಲ್ಲ ಪ್ರತಿ ಇತ್ಯಾದಿ ಇವು ಬೇರೆ ಹೀಗೆ ನಡುವೆ ಇದಕ್ಕೆ ಎಸ್ ಇವರ ಮೊದಲು ಶ್ರೀ ಮಾಡುವ ಇದರಲ್ಲಿ ರೀತಿಯ ಮಾಡಿದ ಕಾಲ ಅಲ್ಲಿ ಮಾಡಲು ಅದೇ ಈಗ ಅವು ಗಳು ಎ ಎಂಬುದು ಅವನು ಅಂದರೆ ಅವರಿಗೆ ಇರುವ ವಿಶೇಷ ಮುಂದೆ ಅವುಗಳ ಮುಂತಾದ ಮೂಲ ಬಿ ಮೀ ಒಂದೇ ಇನ್ನೂ ಹೆಚ್ಚಾಗಿ ಮಾಡಿ ಅವರನ್ನು ಇದೇ ಯ ರೀತಿಯಲ್ಲಿ ಜೊತೆ ಅದರಲ್ಲಿ ಮಾಡಿದರು ನಡೆದ ಆಗ ಮತ್ತೆ ಪೂರ್ವ ಆತ ಬಂದ ಯಾವ ಒಟ್ಟು ಇತರೆ ಹಿಂದೆ ಪ್ರಮಾಣದ ಗಳನ್ನು ಕುರಿತು ಯು ಆದ್ದರಿಂದ ಅಲ್ಲದೆ ನಗರದ ಮೇಲಿನ ಏಕೆಂದರೆ ರಷ್ಟು ಎಂಬುದನ್ನು ಬಾರಿ ಎಂದರೆ ಹಿಂದಿನ ಆದರೂ ಆದ ಸಂಬಂಧಿಸಿದ ಮತ್ತೊಂದು ಸಿ ಆತನ ".split(" ")),e.kn.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var r=e.wordcut;r.init(),e.kn.tokenizer=function(t){if(!arguments.length||null==t||void 0==t)return[];if(Array.isArray(t))return t.map(function(r){return isLunr2?new e.Token(r.toLowerCase()):r.toLowerCase()});var n=t.toString().toLowerCase().replace(/^\s+/,"");return r.cut(n).split("|")},e.Pipeline.registerFunction(e.kn.stemmer,"stemmer-kn"),e.Pipeline.registerFunction(e.kn.stopWordFilter,"stopWordFilter-kn")}});
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
!function(e,t){"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():t()(e.lunr)}(this,function(){return function(e){e.multiLanguage=function(){for(var t=Array.prototype.slice.call(arguments),i=t.join("-"),r="",n=[],s=[],p=0;p<t.length;++p)"en"==t[p]?(r+="\\w",n.unshift(e.stopWordFilter),n.push(e.stemmer),s.push(e.stemmer)):(r+=e[t[p]].wordCharacters,e[t[p]].stopWordFilter&&n.unshift(e[t[p]].stopWordFilter),e[t[p]].stemmer&&(n.push(e[t[p]].stemmer),s.push(e[t[p]].stemmer)));var o=e.trimmerSupport.generateTrimmer(r);return e.Pipeline.registerFunction(o,"lunr-multi-trimmer-"+i),n.unshift(o),function(){this.pipeline.reset(),this.pipeline.add.apply(this.pipeline,n),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add.apply(this.searchPipeline,s))}}}});
File diff suppressed because one or more lines are too long
-18
View File
@@ -1,18 +0,0 @@
/*!
* Lunr languages, `Norwegian` language
* https://github.com/MihaiValentin/lunr-languages
*
* Copyright 2014, Mihai Valentin
* http://www.mozilla.org/MPL/
*/
/*!
* based on
* Snowball JavaScript Library v0.3
* http://code.google.com/p/urim/
* http://snowball.tartarus.org/
*
* Copyright 2010, Oleg Mazko
* http://www.mozilla.org/MPL/
*/
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.no=function(){this.pipeline.reset(),this.pipeline.add(e.no.trimmer,e.no.stopWordFilter,e.no.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.no.stemmer))},e.no.wordCharacters="A-Za-zªºÀ-ÖØ-öø-ʸˠ-ˤᴀ-ᴥᴬ-ᵜᵢ-ᵥᵫ-ᵷᵹ-ᶾḀ-ỿⁱⁿₐ-ₜKÅℲⅎⅠ-ↈⱠ-ⱿꜢ-ꞇꞋ-ꞭꞰ-ꞷꟷ-ꟿꬰ-ꭚꭜ-ꭤff-stA-Za-z",e.no.trimmer=e.trimmerSupport.generateTrimmer(e.no.wordCharacters),e.Pipeline.registerFunction(e.no.trimmer,"trimmer-no"),e.no.stemmer=function(){var r=e.stemmerSupport.Among,n=e.stemmerSupport.SnowballProgram,i=new function(){function e(){var e,r=w.cursor+3;if(a=w.limit,0<=r||r<=w.limit){for(s=r;;){if(e=w.cursor,w.in_grouping(d,97,248)){w.cursor=e;break}if(e>=w.limit)return;w.cursor=e+1}for(;!w.out_grouping(d,97,248);){if(w.cursor>=w.limit)return;w.cursor++}a=w.cursor,a<s&&(a=s)}}function i(){var e,r,n;if(w.cursor>=a&&(r=w.limit_backward,w.limit_backward=a,w.ket=w.cursor,e=w.find_among_b(m,29),w.limit_backward=r,e))switch(w.bra=w.cursor,e){case 1:w.slice_del();break;case 2:n=w.limit-w.cursor,w.in_grouping_b(c,98,122)?w.slice_del():(w.cursor=w.limit-n,w.eq_s_b(1,"k")&&w.out_grouping_b(d,97,248)&&w.slice_del());break;case 3:w.slice_from("er")}}function t(){var e,r=w.limit-w.cursor;w.cursor>=a&&(e=w.limit_backward,w.limit_backward=a,w.ket=w.cursor,w.find_among_b(u,2)?(w.bra=w.cursor,w.limit_backward=e,w.cursor=w.limit-r,w.cursor>w.limit_backward&&(w.cursor--,w.bra=w.cursor,w.slice_del())):w.limit_backward=e)}function o(){var e,r;w.cursor>=a&&(r=w.limit_backward,w.limit_backward=a,w.ket=w.cursor,e=w.find_among_b(l,11),e?(w.bra=w.cursor,w.limit_backward=r,1==e&&w.slice_del()):w.limit_backward=r)}var s,a,m=[new r("a",-1,1),new r("e",-1,1),new r("ede",1,1),new r("ande",1,1),new r("ende",1,1),new r("ane",1,1),new r("ene",1,1),new r("hetene",6,1),new r("erte",1,3),new r("en",-1,1),new r("heten",9,1),new r("ar",-1,1),new r("er",-1,1),new r("heter",12,1),new r("s",-1,2),new r("as",14,1),new r("es",14,1),new r("edes",16,1),new r("endes",16,1),new r("enes",16,1),new r("hetenes",19,1),new r("ens",14,1),new r("hetens",21,1),new r("ers",14,1),new r("ets",14,1),new r("et",-1,1),new r("het",25,1),new r("ert",-1,3),new r("ast",-1,1)],u=[new r("dt",-1,-1),new r("vt",-1,-1)],l=[new r("leg",-1,1),new r("eleg",0,1),new r("ig",-1,1),new r("eig",2,1),new r("lig",2,1),new r("elig",4,1),new r("els",-1,1),new r("lov",-1,1),new r("elov",7,1),new r("slov",7,1),new r("hetslov",9,1)],d=[17,65,16,1,0,0,0,0,0,0,0,0,0,0,0,0,48,0,128],c=[119,125,149,1],w=new n;this.setCurrent=function(e){w.setCurrent(e)},this.getCurrent=function(){return w.getCurrent()},this.stem=function(){var r=w.cursor;return e(),w.limit_backward=r,w.cursor=w.limit,i(),w.cursor=w.limit,t(),w.cursor=w.limit,o(),!0}};return function(e){return"function"==typeof e.update?e.update(function(e){return i.setCurrent(e),i.stem(),i.getCurrent()}):(i.setCurrent(e),i.stem(),i.getCurrent())}}(),e.Pipeline.registerFunction(e.no.stemmer,"stemmer-no"),e.no.stopWordFilter=e.generateStopWordFilter("alle at av bare begge ble blei bli blir blitt både båe da de deg dei deim deira deires dem den denne der dere deres det dette di din disse ditt du dykk dykkar då eg ein eit eitt eller elles en enn er et ett etter for fordi fra før ha hadde han hans har hennar henne hennes her hjå ho hoe honom hoss hossen hun hva hvem hver hvilke hvilken hvis hvor hvordan hvorfor i ikke ikkje ikkje ingen ingi inkje inn inni ja jeg kan kom korleis korso kun kunne kva kvar kvarhelst kven kvi kvifor man mange me med medan meg meget mellom men mi min mine mitt mot mykje ned no noe noen noka noko nokon nokor nokre nå når og også om opp oss over på samme seg selv si si sia sidan siden sin sine sitt sjøl skal skulle slik so som som somme somt så sånn til um upp ut uten var vart varte ved vere verte vi vil ville vore vors vort vår være være vært å".split(" ")),e.Pipeline.registerFunction(e.no.stopWordFilter,"stopWordFilter-no")}});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.sa=function(){this.pipeline.reset(),this.pipeline.add(e.sa.trimmer,e.sa.stopWordFilter,e.sa.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.sa.stemmer))},e.sa.wordCharacters="ऀ-ःऄ-एऐ-टठ-यर-िी-ॏॐ-य़ॠ-९॰-ॿ꣠-꣱ꣲ-ꣷ꣸-ꣻ꣼-ꣽꣾ-ꣿᆰ0-ᆰ9",e.sa.trimmer=e.trimmerSupport.generateTrimmer(e.sa.wordCharacters),e.Pipeline.registerFunction(e.sa.trimmer,"trimmer-sa"),e.sa.stopWordFilter=e.generateStopWordFilter('तथा अयम्‌ एकम्‌ इत्यस्मिन्‌ तथा तत्‌ वा अयम्‌ इत्यस्य ते आहूत उपरि तेषाम्‌ किन्तु तेषाम्‌ तदा इत्यनेन अधिकः इत्यस्य तत्‌ केचन बहवः द्वि तथा महत्वपूर्णः अयम्‌ अस्य विषये अयं अस्ति तत्‌ प्रथमः विषये इत्युपरि इत्युपरि इतर अधिकतमः अधिकः अपि सामान्यतया ठ इतरेतर नूतनम्‌ द न्यूनम्‌ कश्चित्‌ वा विशालः द सः अस्ति तदनुसारम् तत्र अस्ति केवलम्‌ अपि अत्र सर्वे विविधाः तत्‌ बहवः यतः इदानीम्‌ द दक्षिण इत्यस्मै तस्य उपरि नथ अतीव कार्यम्‌ सर्वे एकैकम्‌ इत्यादि। एते सन्ति उत इत्थम्‌ मध्ये एतदर्थं . स कस्य प्रथमः श्री. करोति अस्मिन् प्रकारः निर्मिता कालः तत्र कर्तुं समान अधुना ते सन्ति स एकः अस्ति सः अर्थात् तेषां कृते . स्थितम् विशेषः अग्रिम तेषाम्‌ समान स्रोतः ख म समान इदानीमपि अधिकतया करोतु ते समान इत्यस्य वीथी सह यस्मिन् कृतवान्‌ धृतः तदा पुनः पूर्वं सः आगतः किम्‌ कुल इतर पुरा मात्रा स विषये उ अतएव अपि नगरस्य उपरि यतः प्रतिशतं कतरः कालः साधनानि भूत तथापि जात सम्बन्धि अन्यत्‌ ग अतः अस्माकं स्वकीयाः अस्माकं इदानीं अन्तः इत्यादयः भवन्तः इत्यादयः एते एताः तस्य अस्य इदम् एते तेषां तेषां तेषां तान् तेषां तेषां तेषां समानः सः एकः च तादृशाः बहवः अन्ये च वदन्ति यत् कियत् कस्मै कस्मै यस्मै यस्मै यस्मै यस्मै न अतिनीचः किन्तु प्रथमं सम्पूर्णतया ततः चिरकालानन्तरं पुस्तकं सम्पूर्णतया अन्तः किन्तु अत्र वा इह इव श्रद्धाय अवशिष्यते परन्तु अन्ये वर्गाः सन्ति ते सन्ति शक्नुवन्ति सर्वे मिलित्वा सर्वे एकत्र"'.split(" ")),e.sa.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var r=e.wordcut;r.init(),e.sa.tokenizer=function(t){if(!arguments.length||null==t||void 0==t)return[];if(Array.isArray(t))return t.map(function(r){return isLunr2?new e.Token(r.toLowerCase()):r.toLowerCase()});var i=t.toString().toLowerCase().replace(/^\s+/,"");return r.cut(i).split("|")},e.Pipeline.registerFunction(e.sa.stemmer,"stemmer-sa"),e.Pipeline.registerFunction(e.sa.stopWordFilter,"stopWordFilter-sa")}});
@@ -1 +0,0 @@
!function(r,t){"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():t()(r.lunr)}(this,function(){return function(r){r.stemmerSupport={Among:function(r,t,i,s){if(this.toCharArray=function(r){for(var t=r.length,i=new Array(t),s=0;s<t;s++)i[s]=r.charCodeAt(s);return i},!r&&""!=r||!t&&0!=t||!i)throw"Bad Among initialisation: s:"+r+", substring_i: "+t+", result: "+i;this.s_size=r.length,this.s=this.toCharArray(r),this.substring_i=t,this.result=i,this.method=s},SnowballProgram:function(){var r;return{bra:0,ket:0,limit:0,cursor:0,limit_backward:0,setCurrent:function(t){r=t,this.cursor=0,this.limit=t.length,this.limit_backward=0,this.bra=this.cursor,this.ket=this.limit},getCurrent:function(){var t=r;return r=null,t},in_grouping:function(t,i,s){if(this.cursor<this.limit){var e=r.charCodeAt(this.cursor);if(e<=s&&e>=i&&(e-=i,t[e>>3]&1<<(7&e)))return this.cursor++,!0}return!1},in_grouping_b:function(t,i,s){if(this.cursor>this.limit_backward){var e=r.charCodeAt(this.cursor-1);if(e<=s&&e>=i&&(e-=i,t[e>>3]&1<<(7&e)))return this.cursor--,!0}return!1},out_grouping:function(t,i,s){if(this.cursor<this.limit){var e=r.charCodeAt(this.cursor);if(e>s||e<i)return this.cursor++,!0;if(e-=i,!(t[e>>3]&1<<(7&e)))return this.cursor++,!0}return!1},out_grouping_b:function(t,i,s){if(this.cursor>this.limit_backward){var e=r.charCodeAt(this.cursor-1);if(e>s||e<i)return this.cursor--,!0;if(e-=i,!(t[e>>3]&1<<(7&e)))return this.cursor--,!0}return!1},eq_s:function(t,i){if(this.limit-this.cursor<t)return!1;for(var s=0;s<t;s++)if(r.charCodeAt(this.cursor+s)!=i.charCodeAt(s))return!1;return this.cursor+=t,!0},eq_s_b:function(t,i){if(this.cursor-this.limit_backward<t)return!1;for(var s=0;s<t;s++)if(r.charCodeAt(this.cursor-t+s)!=i.charCodeAt(s))return!1;return this.cursor-=t,!0},find_among:function(t,i){for(var s=0,e=i,n=this.cursor,u=this.limit,o=0,h=0,c=!1;;){for(var a=s+(e-s>>1),f=0,l=o<h?o:h,_=t[a],m=l;m<_.s_size;m++){if(n+l==u){f=-1;break}if(f=r.charCodeAt(n+l)-_.s[m])break;l++}if(f<0?(e=a,h=l):(s=a,o=l),e-s<=1){if(s>0||e==s||c)break;c=!0}}for(;;){var _=t[s];if(o>=_.s_size){if(this.cursor=n+_.s_size,!_.method)return _.result;var b=_.method();if(this.cursor=n+_.s_size,b)return _.result}if((s=_.substring_i)<0)return 0}},find_among_b:function(t,i){for(var s=0,e=i,n=this.cursor,u=this.limit_backward,o=0,h=0,c=!1;;){for(var a=s+(e-s>>1),f=0,l=o<h?o:h,_=t[a],m=_.s_size-1-l;m>=0;m--){if(n-l==u){f=-1;break}if(f=r.charCodeAt(n-1-l)-_.s[m])break;l++}if(f<0?(e=a,h=l):(s=a,o=l),e-s<=1){if(s>0||e==s||c)break;c=!0}}for(;;){var _=t[s];if(o>=_.s_size){if(this.cursor=n-_.s_size,!_.method)return _.result;var b=_.method();if(this.cursor=n-_.s_size,b)return _.result}if((s=_.substring_i)<0)return 0}},replace_s:function(t,i,s){var e=s.length-(i-t),n=r.substring(0,t),u=r.substring(i);return r=n+s+u,this.limit+=e,this.cursor>=i?this.cursor+=e:this.cursor>t&&(this.cursor=t),e},slice_check:function(){if(this.bra<0||this.bra>this.ket||this.ket>this.limit||this.limit>r.length)throw"faulty slice operation"},slice_from:function(r){this.slice_check(),this.replace_s(this.bra,this.ket,r)},slice_del:function(){this.slice_from("")},insert:function(r,t,i){var s=this.replace_s(r,t,i);r<=this.bra&&(this.bra+=s),r<=this.ket&&(this.ket+=s)},slice_to:function(){return this.slice_check(),r.substring(this.bra,this.ket)},eq_v_b:function(r){return this.eq_s_b(r.length,r)}}}},r.trimmerSupport={generateTrimmer:function(r){var t=new RegExp("^[^"+r+"]+"),i=new RegExp("[^"+r+"]+$");return function(r){return"function"==typeof r.update?r.update(function(r){return r.replace(t,"").replace(i,"")}):r.replace(t,"").replace(i,"")}}}}});
-18
View File
@@ -1,18 +0,0 @@
/*!
* Lunr languages, `Swedish` language
* https://github.com/MihaiValentin/lunr-languages
*
* Copyright 2014, Mihai Valentin
* http://www.mozilla.org/MPL/
*/
/*!
* based on
* Snowball JavaScript Library v0.3
* http://code.google.com/p/urim/
* http://snowball.tartarus.org/
*
* Copyright 2010, Oleg Mazko
* http://www.mozilla.org/MPL/
*/
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.sv=function(){this.pipeline.reset(),this.pipeline.add(e.sv.trimmer,e.sv.stopWordFilter,e.sv.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.sv.stemmer))},e.sv.wordCharacters="A-Za-zªºÀ-ÖØ-öø-ʸˠ-ˤᴀ-ᴥᴬ-ᵜᵢ-ᵥᵫ-ᵷᵹ-ᶾḀ-ỿⁱⁿₐ-ₜKÅℲⅎⅠ-ↈⱠ-ⱿꜢ-ꞇꞋ-ꞭꞰ-ꞷꟷ-ꟿꬰ-ꭚꭜ-ꭤff-stA-Za-z",e.sv.trimmer=e.trimmerSupport.generateTrimmer(e.sv.wordCharacters),e.Pipeline.registerFunction(e.sv.trimmer,"trimmer-sv"),e.sv.stemmer=function(){var r=e.stemmerSupport.Among,n=e.stemmerSupport.SnowballProgram,t=new function(){function e(){var e,r=w.cursor+3;if(o=w.limit,0<=r||r<=w.limit){for(a=r;;){if(e=w.cursor,w.in_grouping(l,97,246)){w.cursor=e;break}if(w.cursor=e,w.cursor>=w.limit)return;w.cursor++}for(;!w.out_grouping(l,97,246);){if(w.cursor>=w.limit)return;w.cursor++}o=w.cursor,o<a&&(o=a)}}function t(){var e,r=w.limit_backward;if(w.cursor>=o&&(w.limit_backward=o,w.cursor=w.limit,w.ket=w.cursor,e=w.find_among_b(u,37),w.limit_backward=r,e))switch(w.bra=w.cursor,e){case 1:w.slice_del();break;case 2:w.in_grouping_b(d,98,121)&&w.slice_del()}}function i(){var e=w.limit_backward;w.cursor>=o&&(w.limit_backward=o,w.cursor=w.limit,w.find_among_b(c,7)&&(w.cursor=w.limit,w.ket=w.cursor,w.cursor>w.limit_backward&&(w.bra=--w.cursor,w.slice_del())),w.limit_backward=e)}function s(){var e,r;if(w.cursor>=o){if(r=w.limit_backward,w.limit_backward=o,w.cursor=w.limit,w.ket=w.cursor,e=w.find_among_b(m,5))switch(w.bra=w.cursor,e){case 1:w.slice_del();break;case 2:w.slice_from("lös");break;case 3:w.slice_from("full")}w.limit_backward=r}}var a,o,u=[new r("a",-1,1),new r("arna",0,1),new r("erna",0,1),new r("heterna",2,1),new r("orna",0,1),new r("ad",-1,1),new r("e",-1,1),new r("ade",6,1),new r("ande",6,1),new r("arne",6,1),new r("are",6,1),new r("aste",6,1),new r("en",-1,1),new r("anden",12,1),new r("aren",12,1),new r("heten",12,1),new r("ern",-1,1),new r("ar",-1,1),new r("er",-1,1),new r("heter",18,1),new r("or",-1,1),new r("s",-1,2),new r("as",21,1),new r("arnas",22,1),new r("ernas",22,1),new r("ornas",22,1),new r("es",21,1),new r("ades",26,1),new r("andes",26,1),new r("ens",21,1),new r("arens",29,1),new r("hetens",29,1),new r("erns",21,1),new r("at",-1,1),new r("andet",-1,1),new r("het",-1,1),new r("ast",-1,1)],c=[new r("dd",-1,-1),new r("gd",-1,-1),new r("nn",-1,-1),new r("dt",-1,-1),new r("gt",-1,-1),new r("kt",-1,-1),new r("tt",-1,-1)],m=[new r("ig",-1,1),new r("lig",0,1),new r("els",-1,1),new r("fullt",-1,3),new r("löst",-1,2)],l=[17,65,16,1,0,0,0,0,0,0,0,0,0,0,0,0,24,0,32],d=[119,127,149],w=new n;this.setCurrent=function(e){w.setCurrent(e)},this.getCurrent=function(){return w.getCurrent()},this.stem=function(){var r=w.cursor;return e(),w.limit_backward=r,w.cursor=w.limit,t(),w.cursor=w.limit,i(),w.cursor=w.limit,s(),!0}};return function(e){return"function"==typeof e.update?e.update(function(e){return t.setCurrent(e),t.stem(),t.getCurrent()}):(t.setCurrent(e),t.stem(),t.getCurrent())}}(),e.Pipeline.registerFunction(e.sv.stemmer,"stemmer-sv"),e.sv.stopWordFilter=e.generateStopWordFilter("alla allt att av blev bli blir blivit de dem den denna deras dess dessa det detta dig din dina ditt du där då efter ej eller en er era ert ett från för ha hade han hans har henne hennes hon honom hur här i icke ingen inom inte jag ju kan kunde man med mellan men mig min mina mitt mot mycket ni nu när någon något några och om oss på samma sedan sig sin sina sitta själv skulle som så sådan sådana sådant till under upp ut utan vad var vara varför varit varje vars vart vem vi vid vilka vilkas vilken vilket vår våra vårt än är åt över".split(" ")),e.Pipeline.registerFunction(e.sv.stopWordFilter,"stopWordFilter-sv")}});
-1
View File
@@ -1 +0,0 @@
!function(e,t){"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():t()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.ta=function(){this.pipeline.reset(),this.pipeline.add(e.ta.trimmer,e.ta.stopWordFilter,e.ta.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.ta.stemmer))},e.ta.wordCharacters="஀-உஊ-ஏஐ-ஙச-ட஠-னப-யர-ஹ஺-ிீ-௉ொ-௏ௐ-௙௚-௟௠-௩௪-௯௰-௹௺-௿a-zA-Z-zA-0-9-",e.ta.trimmer=e.trimmerSupport.generateTrimmer(e.ta.wordCharacters),e.Pipeline.registerFunction(e.ta.trimmer,"trimmer-ta"),e.ta.stopWordFilter=e.generateStopWordFilter("அங்கு அங்கே அது அதை அந்த அவர் அவர்கள் அவள் அவன் அவை ஆக ஆகவே ஆகையால் ஆதலால் ஆதலினால் ஆனாலும் ஆனால் இங்கு இங்கே இது இதை இந்த இப்படி இவர் இவர்கள் இவள் இவன் இவை இவ்வளவு உனக்கு உனது உன் உன்னால் எங்கு எங்கே எது எதை எந்த எப்படி எவர் எவர்கள் எவள் எவன் எவை எவ்வளவு எனக்கு எனது எனவே என் என்ன என்னால் ஏது ஏன் தனது தன்னால் தானே தான் நாங்கள் நாம் நான் நீ நீங்கள்".split(" ")),e.ta.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var t=e.wordcut;t.init(),e.ta.tokenizer=function(r){if(!arguments.length||null==r||void 0==r)return[];if(Array.isArray(r))return r.map(function(t){return isLunr2?new e.Token(t.toLowerCase()):t.toLowerCase()});var i=r.toString().toLowerCase().replace(/^\s+/,"");return t.cut(i).split("|")},e.Pipeline.registerFunction(e.ta.stemmer,"stemmer-ta"),e.Pipeline.registerFunction(e.ta.stopWordFilter,"stopWordFilter-ta")}});
-1
View File
@@ -1 +0,0 @@
!function(e,t){"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():t()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.te=function(){this.pipeline.reset(),this.pipeline.add(e.te.trimmer,e.te.stopWordFilter,e.te.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.te.stemmer))},e.te.wordCharacters="ఀ-ఄఅ-ఔక-హా-ౌౕ-ౖౘ-ౚౠ-ౡౢ-ౣ౦-౯౸-౿఼ఽ్ౝ౷౤౥",e.te.trimmer=e.trimmerSupport.generateTrimmer(e.te.wordCharacters),e.Pipeline.registerFunction(e.te.trimmer,"trimmer-te"),e.te.stopWordFilter=e.generateStopWordFilter("అందరూ అందుబాటులో అడగండి అడగడం అడ్డంగా అనుగుణంగా అనుమతించు అనుమతిస్తుంది అయితే ఇప్పటికే ఉన్నారు ఎక్కడైనా ఎప్పుడు ఎవరైనా ఎవరో ఏ ఏదైనా ఏమైనప్పటికి ఒక ఒకరు కనిపిస్తాయి కాదు కూడా గా గురించి చుట్టూ చేయగలిగింది తగిన తర్వాత దాదాపు దూరంగా నిజంగా పై ప్రకారం ప్రక్కన మధ్య మరియు మరొక మళ్ళీ మాత్రమే మెచ్చుకో వద్ద వెంట వేరుగా వ్యతిరేకంగా సంబంధం".split(" ")),e.te.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var t=e.wordcut;t.init(),e.te.tokenizer=function(r){if(!arguments.length||null==r||void 0==r)return[];if(Array.isArray(r))return r.map(function(t){return isLunr2?new e.Token(t.toLowerCase()):t.toLowerCase()});var i=r.toString().toLowerCase().replace(/^\s+/,"");return t.cut(i).split("|")},e.Pipeline.registerFunction(e.te.stemmer,"stemmer-te"),e.Pipeline.registerFunction(e.te.stopWordFilter,"stopWordFilter-te")}});
-1
View File
@@ -1 +0,0 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");var r="2"==e.version[0];e.th=function(){this.pipeline.reset(),this.pipeline.add(e.th.trimmer),r?this.tokenizer=e.th.tokenizer:(e.tokenizer&&(e.tokenizer=e.th.tokenizer),this.tokenizerFn&&(this.tokenizerFn=e.th.tokenizer))},e.th.wordCharacters="[฀-๿]",e.th.trimmer=e.trimmerSupport.generateTrimmer(e.th.wordCharacters),e.Pipeline.registerFunction(e.th.trimmer,"trimmer-th");var t=e.wordcut;t.init(),e.th.tokenizer=function(i){if(!arguments.length||null==i||void 0==i)return[];if(Array.isArray(i))return i.map(function(t){return r?new e.Token(t):t});var n=i.toString().replace(/^\s+/,"");return t.cut(n).split("|")}}});
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.vi=function(){this.pipeline.reset(),this.pipeline.add(e.vi.stopWordFilter,e.vi.trimmer)},e.vi.wordCharacters="[A-Za-ẓ̀͐́͑̉̃̓ÂâÊêÔôĂ-ăĐ-đƠ-ơƯ-ư]",e.vi.trimmer=e.trimmerSupport.generateTrimmer(e.vi.wordCharacters),e.Pipeline.registerFunction(e.vi.trimmer,"trimmer-vi"),e.vi.stopWordFilter=e.generateStopWordFilter("là cái nhưng mà".split(" "))}});
-1
View File
@@ -1 +0,0 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r(require("@node-rs/jieba")):r()(e.lunr)}(this,function(e){return function(r,t){if(void 0===r)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===r.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");var i="2"==r.version[0];r.zh=function(){this.pipeline.reset(),this.pipeline.add(r.zh.trimmer,r.zh.stopWordFilter,r.zh.stemmer),i?this.tokenizer=r.zh.tokenizer:(r.tokenizer&&(r.tokenizer=r.zh.tokenizer),this.tokenizerFn&&(this.tokenizerFn=r.zh.tokenizer))},r.zh.tokenizer=function(n){if(!arguments.length||null==n||void 0==n)return[];if(Array.isArray(n))return n.map(function(e){return i?new r.Token(e.toLowerCase()):e.toLowerCase()});t&&e.load(t);var o=n.toString().trim().toLowerCase(),s=[];e.cut(o,!0).forEach(function(e){s=s.concat(e.split(" "))}),s=s.filter(function(e){return!!e});var u=0;return s.map(function(e,t){if(i){var n=o.indexOf(e,u),s={};return s.position=[n,e.length],s.index=t,u=n,new r.Token(e,s)}return e})},r.zh.wordCharacters="\\w一-龥",r.zh.trimmer=r.trimmerSupport.generateTrimmer(r.zh.wordCharacters),r.Pipeline.registerFunction(r.zh.trimmer,"trimmer-zh"),r.zh.stemmer=function(){return function(e){return e}}(),r.Pipeline.registerFunction(r.zh.stemmer,"stemmer-zh"),r.zh.stopWordFilter=r.generateStopWordFilter("的 一 不 在 人 有 是 为 為 以 于 於 上 他 而 后 後 之 来 來 及 了 因 下 可 到 由 这 這 与 與 也 此 但 并 並 个 個 其 已 无 無 小 我 们 們 起 最 再 今 去 好 只 又 或 很 亦 某 把 那 你 乃 它 吧 被 比 别 趁 当 當 从 從 得 打 凡 儿 兒 尔 爾 该 該 各 给 給 跟 和 何 还 還 即 几 幾 既 看 据 據 距 靠 啦 另 么 麽 每 嘛 拿 哪 您 凭 憑 且 却 卻 让 讓 仍 啥 如 若 使 谁 誰 虽 雖 随 隨 同 所 她 哇 嗡 往 些 向 沿 哟 喲 用 咱 则 則 怎 曾 至 致 着 著 诸 諸 自".split(" ")),r.Pipeline.registerFunction(r.zh.stopWordFilter,"stopWordFilter-zh")}});
@@ -1,206 +0,0 @@
/**
* export the module via AMD, CommonJS or as a browser global
* Export code from https://github.com/umdjs/umd/blob/master/returnExports.js
*/
;(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(factory)
} else if (typeof exports === 'object') {
/**
* Node. Does not work with strict CommonJS, but
* only CommonJS-like environments that support module.exports,
* like Node.
*/
module.exports = factory()
} else {
// Browser globals (root is window)
factory()(root.lunr);
}
}(this, function () {
/**
* Just return a value to define the module export.
* This example returns an object, but the module
* can return a function as the exported value.
*/
return function(lunr) {
// TinySegmenter 0.1 -- Super compact Japanese tokenizer in Javascript
// (c) 2008 Taku Kudo <taku@chasen.org>
// TinySegmenter is freely distributable under the terms of a new BSD licence.
// For details, see http://chasen.org/~taku/software/TinySegmenter/LICENCE.txt
function TinySegmenter() {
var patterns = {
"[一二三四五六七八九十百千万億兆]":"M",
"[一-龠々〆ヵヶ]":"H",
"[ぁ-ん]":"I",
"[ァ-ヴーア-ン゙ー]":"K",
"[a-zA-Z-zA-]":"A",
"[0-9-]":"N"
}
this.chartype_ = [];
for (var i in patterns) {
var regexp = new RegExp(i);
this.chartype_.push([regexp, patterns[i]]);
}
this.BIAS__ = -332
this.BC1__ = {"HH":6,"II":2461,"KH":406,"OH":-1378};
this.BC2__ = {"AA":-3267,"AI":2744,"AN":-878,"HH":-4070,"HM":-1711,"HN":4012,"HO":3761,"IA":1327,"IH":-1184,"II":-1332,"IK":1721,"IO":5492,"KI":3831,"KK":-8741,"MH":-3132,"MK":3334,"OO":-2920};
this.BC3__ = {"HH":996,"HI":626,"HK":-721,"HN":-1307,"HO":-836,"IH":-301,"KK":2762,"MK":1079,"MM":4034,"OA":-1652,"OH":266};
this.BP1__ = {"BB":295,"OB":304,"OO":-125,"UB":352};
this.BP2__ = {"BO":60,"OO":-1762};
this.BQ1__ = {"BHH":1150,"BHM":1521,"BII":-1158,"BIM":886,"BMH":1208,"BNH":449,"BOH":-91,"BOO":-2597,"OHI":451,"OIH":-296,"OKA":1851,"OKH":-1020,"OKK":904,"OOO":2965};
this.BQ2__ = {"BHH":118,"BHI":-1159,"BHM":466,"BIH":-919,"BKK":-1720,"BKO":864,"OHH":-1139,"OHM":-181,"OIH":153,"UHI":-1146};
this.BQ3__ = {"BHH":-792,"BHI":2664,"BII":-299,"BKI":419,"BMH":937,"BMM":8335,"BNN":998,"BOH":775,"OHH":2174,"OHM":439,"OII":280,"OKH":1798,"OKI":-793,"OKO":-2242,"OMH":-2402,"OOO":11699};
this.BQ4__ = {"BHH":-3895,"BIH":3761,"BII":-4654,"BIK":1348,"BKK":-1806,"BMI":-3385,"BOO":-12396,"OAH":926,"OHH":266,"OHK":-2036,"ONN":-973};
this.BW1__ = {",と":660,",同":727,"B1あ":1404,"B1同":542,"、と":660,"、同":727,"」と":1682,"あっ":1505,"いう":1743,"いっ":-2055,"いる":672,"うし":-4817,"うん":665,"から":3472,"がら":600,"こう":-790,"こと":2083,"こん":-1262,"さら":-4143,"さん":4573,"した":2641,"して":1104,"すで":-3399,"そこ":1977,"それ":-871,"たち":1122,"ため":601,"った":3463,"つい":-802,"てい":805,"てき":1249,"でき":1127,"です":3445,"では":844,"とい":-4915,"とみ":1922,"どこ":3887,"ない":5713,"なっ":3015,"など":7379,"なん":-1113,"にし":2468,"には":1498,"にも":1671,"に対":-912,"の一":-501,"の中":741,"ませ":2448,"まで":1711,"まま":2600,"まる":-2155,"やむ":-1947,"よっ":-2565,"れた":2369,"れで":-913,"をし":1860,"を見":731,"亡く":-1886,"京都":2558,"取り":-2784,"大き":-2604,"大阪":1497,"平方":-2314,"引き":-1336,"日本":-195,"本当":-2423,"毎日":-2113,"目指":-724,"B1あ":1404,"B1同":542,"」と":1682};
this.BW2__ = {"..":-11822,"11":-669,"――":-5730,"−−":-13175,"いう":-1609,"うか":2490,"かし":-1350,"かも":-602,"から":-7194,"かれ":4612,"がい":853,"がら":-3198,"きた":1941,"くな":-1597,"こと":-8392,"この":-4193,"させ":4533,"され":13168,"さん":-3977,"しい":-1819,"しか":-545,"した":5078,"して":972,"しな":939,"その":-3744,"たい":-1253,"たた":-662,"ただ":-3857,"たち":-786,"たと":1224,"たは":-939,"った":4589,"って":1647,"っと":-2094,"てい":6144,"てき":3640,"てく":2551,"ては":-3110,"ても":-3065,"でい":2666,"でき":-1528,"でし":-3828,"です":-4761,"でも":-4203,"とい":1890,"とこ":-1746,"とと":-2279,"との":720,"とみ":5168,"とも":-3941,"ない":-2488,"なが":-1313,"など":-6509,"なの":2614,"なん":3099,"にお":-1615,"にし":2748,"にな":2454,"によ":-7236,"に対":-14943,"に従":-4688,"に関":-11388,"のか":2093,"ので":-7059,"のに":-6041,"のの":-6125,"はい":1073,"はが":-1033,"はず":-2532,"ばれ":1813,"まし":-1316,"まで":-6621,"まれ":5409,"めて":-3153,"もい":2230,"もの":-10713,"らか":-944,"らし":-1611,"らに":-1897,"りし":651,"りま":1620,"れた":4270,"れて":849,"れば":4114,"ろう":6067,"われ":7901,"を通":-11877,"んだ":728,"んな":-4115,"一人":602,"一方":-1375,"一日":970,"一部":-1051,"上が":-4479,"会社":-1116,"出て":2163,"分の":-7758,"同党":970,"同日":-913,"大阪":-2471,"委員":-1250,"少な":-1050,"年度":-8669,"年間":-1626,"府県":-2363,"手権":-1982,"新聞":-4066,"日新":-722,"日本":-7068,"日米":3372,"曜日":-601,"朝鮮":-2355,"本人":-2697,"東京":-1543,"然と":-1384,"社会":-1276,"立て":-990,"第に":-1612,"米国":-4268,"11":-669};
this.BW3__ = {"あた":-2194,"あり":719,"ある":3846,"い.":-1185,"い。":-1185,"いい":5308,"いえ":2079,"いく":3029,"いた":2056,"いっ":1883,"いる":5600,"いわ":1527,"うち":1117,"うと":4798,"えと":1454,"か.":2857,"か。":2857,"かけ":-743,"かっ":-4098,"かに":-669,"から":6520,"かり":-2670,"が,":1816,"が、":1816,"がき":-4855,"がけ":-1127,"がっ":-913,"がら":-4977,"がり":-2064,"きた":1645,"けど":1374,"こと":7397,"この":1542,"ころ":-2757,"さい":-714,"さを":976,"し,":1557,"し、":1557,"しい":-3714,"した":3562,"して":1449,"しな":2608,"しま":1200,"す.":-1310,"す。":-1310,"する":6521,"ず,":3426,"ず、":3426,"ずに":841,"そう":428,"た.":8875,"た。":8875,"たい":-594,"たの":812,"たり":-1183,"たる":-853,"だ.":4098,"だ。":4098,"だっ":1004,"った":-4748,"って":300,"てい":6240,"てお":855,"ても":302,"です":1437,"でに":-1482,"では":2295,"とう":-1387,"とし":2266,"との":541,"とも":-3543,"どう":4664,"ない":1796,"なく":-903,"など":2135,"に,":-1021,"に、":-1021,"にし":1771,"にな":1906,"には":2644,"の,":-724,"の、":-724,"の子":-1000,"は,":1337,"は、":1337,"べき":2181,"まし":1113,"ます":6943,"まっ":-1549,"まで":6154,"まれ":-793,"らし":1479,"られ":6820,"るる":3818,"れ,":854,"れ、":854,"れた":1850,"れて":1375,"れば":-3246,"れる":1091,"われ":-605,"んだ":606,"んで":798,"カ月":990,"会議":860,"入り":1232,"大会":2217,"始め":1681,"市":965,"新聞":-5055,"日,":974,"日、":974,"社会":2024,"カ月":990};
this.TC1__ = {"AAA":1093,"HHH":1029,"HHM":580,"HII":998,"HOH":-390,"HOM":-331,"IHI":1169,"IOH":-142,"IOI":-1015,"IOM":467,"MMH":187,"OOI":-1832};
this.TC2__ = {"HHO":2088,"HII":-1023,"HMM":-1154,"IHI":-1965,"KKH":703,"OII":-2649};
this.TC3__ = {"AAA":-294,"HHH":346,"HHI":-341,"HII":-1088,"HIK":731,"HOH":-1486,"IHH":128,"IHI":-3041,"IHO":-1935,"IIH":-825,"IIM":-1035,"IOI":-542,"KHH":-1216,"KKA":491,"KKH":-1217,"KOK":-1009,"MHH":-2694,"MHM":-457,"MHO":123,"MMH":-471,"NNH":-1689,"NNO":662,"OHO":-3393};
this.TC4__ = {"HHH":-203,"HHI":1344,"HHK":365,"HHM":-122,"HHN":182,"HHO":669,"HIH":804,"HII":679,"HOH":446,"IHH":695,"IHO":-2324,"IIH":321,"III":1497,"IIO":656,"IOO":54,"KAK":4845,"KKA":3386,"KKK":3065,"MHH":-405,"MHI":201,"MMH":-241,"MMM":661,"MOM":841};
this.TQ1__ = {"BHHH":-227,"BHHI":316,"BHIH":-132,"BIHH":60,"BIII":1595,"BNHH":-744,"BOHH":225,"BOOO":-908,"OAKK":482,"OHHH":281,"OHIH":249,"OIHI":200,"OIIH":-68};
this.TQ2__ = {"BIHH":-1401,"BIII":-1033,"BKAK":-543,"BOOO":-5591};
this.TQ3__ = {"BHHH":478,"BHHM":-1073,"BHIH":222,"BHII":-504,"BIIH":-116,"BIII":-105,"BMHI":-863,"BMHM":-464,"BOMH":620,"OHHH":346,"OHHI":1729,"OHII":997,"OHMH":481,"OIHH":623,"OIIH":1344,"OKAK":2792,"OKHH":587,"OKKA":679,"OOHH":110,"OOII":-685};
this.TQ4__ = {"BHHH":-721,"BHHM":-3604,"BHII":-966,"BIIH":-607,"BIII":-2181,"OAAA":-2763,"OAKK":180,"OHHH":-294,"OHHI":2446,"OHHO":480,"OHIH":-1573,"OIHH":1935,"OIHI":-493,"OIIH":626,"OIII":-4007,"OKAK":-8156};
this.TW1__ = {"につい":-4681,"東京都":2026};
this.TW2__ = {"ある程":-2049,"いった":-1256,"ころが":-2434,"しょう":3873,"その後":-4430,"だって":-1049,"ていた":1833,"として":-4657,"ともに":-4517,"もので":1882,"一気に":-792,"初めて":-1512,"同時に":-8097,"大きな":-1255,"対して":-2721,"社会党":-3216};
this.TW3__ = {"いただ":-1734,"してい":1314,"として":-4314,"につい":-5483,"にとっ":-5989,"に当た":-6247,"ので,":-727,"ので、":-727,"のもの":-600,"れから":-3752,"十二月":-2287};
this.TW4__ = {"いう.":8576,"いう。":8576,"からな":-2348,"してい":2958,"たが,":1516,"たが、":1516,"ている":1538,"という":1349,"ました":5543,"ません":1097,"ようと":-4258,"よると":5865};
this.UC1__ = {"A":484,"K":93,"M":645,"O":-505};
this.UC2__ = {"A":819,"H":1059,"I":409,"M":3987,"N":5775,"O":646};
this.UC3__ = {"A":-1370,"I":2311};
this.UC4__ = {"A":-2643,"H":1809,"I":-1032,"K":-3450,"M":3565,"N":3876,"O":6646};
this.UC5__ = {"H":313,"I":-1238,"K":-799,"M":539,"O":-831};
this.UC6__ = {"H":-506,"I":-253,"K":87,"M":247,"O":-387};
this.UP1__ = {"O":-214};
this.UP2__ = {"B":69,"O":935};
this.UP3__ = {"B":189};
this.UQ1__ = {"BH":21,"BI":-12,"BK":-99,"BN":142,"BO":-56,"OH":-95,"OI":477,"OK":410,"OO":-2422};
this.UQ2__ = {"BH":216,"BI":113,"OK":1759};
this.UQ3__ = {"BA":-479,"BH":42,"BI":1913,"BK":-7198,"BM":3160,"BN":6427,"BO":14761,"OI":-827,"ON":-3212};
this.UW1__ = {",":156,"、":156,"「":-463,"あ":-941,"う":-127,"が":-553,"き":121,"こ":505,"で":-201,"と":-547,"ど":-123,"に":-789,"の":-185,"は":-847,"も":-466,"や":-470,"よ":182,"ら":-292,"り":208,"れ":169,"を":-446,"ん":-137,"・":-135,"主":-402,"京":-268,"区":-912,"午":871,"国":-460,"大":561,"委":729,"市":-411,"日":-141,"理":361,"生":-408,"県":-386,"都":-718,"「":-463,"・":-135};
this.UW2__ = {",":-829,"、":-829,"":892,"「":-645,"」":3145,"あ":-538,"い":505,"う":134,"お":-502,"か":1454,"が":-856,"く":-412,"こ":1141,"さ":878,"ざ":540,"し":1529,"す":-675,"せ":300,"そ":-1011,"た":188,"だ":1837,"つ":-949,"て":-291,"で":-268,"と":-981,"ど":1273,"な":1063,"に":-1764,"の":130,"は":-409,"ひ":-1273,"べ":1261,"ま":600,"も":-1263,"や":-402,"よ":1639,"り":-579,"る":-694,"れ":571,"を":-2516,"ん":2095,"ア":-587,"カ":306,"キ":568,"ッ":831,"三":-758,"不":-2150,"世":-302,"中":-968,"主":-861,"事":492,"人":-123,"会":978,"保":362,"入":548,"初":-3025,"副":-1566,"北":-3414,"区":-422,"大":-1769,"天":-865,"太":-483,"子":-1519,"学":760,"実":1023,"小":-2009,"市":-813,"年":-1060,"強":1067,"手":-1519,"揺":-1033,"政":1522,"文":-1355,"新":-1682,"日":-1815,"明":-1462,"最":-630,"朝":-1843,"本":-1650,"東":-931,"果":-665,"次":-2378,"民":-180,"気":-1740,"理":752,"発":529,"目":-1584,"相":-242,"県":-1165,"立":-763,"第":810,"米":509,"自":-1353,"行":838,"西":-744,"見":-3874,"調":1010,"議":1198,"込":3041,"開":1758,"間":-1257,"「":-645,"」":3145,"ッ":831,"ア":-587,"カ":306,"キ":568};
this.UW3__ = {",":4889,"1":-800,"":-1723,"、":4889,"々":-2311,"":5827,"」":2670,"〓":-3573,"あ":-2696,"い":1006,"う":2342,"え":1983,"お":-4864,"か":-1163,"が":3271,"く":1004,"け":388,"げ":401,"こ":-3552,"ご":-3116,"さ":-1058,"し":-395,"す":584,"せ":3685,"そ":-5228,"た":842,"ち":-521,"っ":-1444,"つ":-1081,"て":6167,"で":2318,"と":1691,"ど":-899,"な":-2788,"に":2745,"の":4056,"は":4555,"ひ":-2171,"ふ":-1798,"へ":1199,"ほ":-5516,"ま":-4384,"み":-120,"め":1205,"も":2323,"や":-788,"よ":-202,"ら":727,"り":649,"る":5905,"れ":2773,"わ":-1207,"を":6620,"ん":-518,"ア":551,"グ":1319,"ス":874,"ッ":-1350,"ト":521,"ム":1109,"ル":1591,"ロ":2201,"ン":278,"・":-3794,"一":-1619,"下":-1759,"世":-2087,"両":3815,"中":653,"主":-758,"予":-1193,"二":974,"人":2742,"今":792,"他":1889,"以":-1368,"低":811,"何":4265,"作":-361,"保":-2439,"元":4858,"党":3593,"全":1574,"公":-3030,"六":755,"共":-1880,"円":5807,"再":3095,"分":457,"初":2475,"別":1129,"前":2286,"副":4437,"力":365,"動":-949,"務":-1872,"化":1327,"北":-1038,"区":4646,"千":-2309,"午":-783,"協":-1006,"口":483,"右":1233,"各":3588,"合":-241,"同":3906,"和":-837,"員":4513,"国":642,"型":1389,"場":1219,"外":-241,"妻":2016,"学":-1356,"安":-423,"実":-1008,"家":1078,"小":-513,"少":-3102,"州":1155,"市":3197,"平":-1804,"年":2416,"広":-1030,"府":1605,"度":1452,"建":-2352,"当":-3885,"得":1905,"思":-1291,"性":1822,"戸":-488,"指":-3973,"政":-2013,"教":-1479,"数":3222,"文":-1489,"新":1764,"日":2099,"旧":5792,"昨":-661,"時":-1248,"曜":-951,"最":-937,"月":4125,"期":360,"李":3094,"村":364,"東":-805,"核":5156,"森":2438,"業":484,"氏":2613,"民":-1694,"決":-1073,"法":1868,"海":-495,"無":979,"物":461,"特":-3850,"生":-273,"用":914,"町":1215,"的":7313,"直":-1835,"省":792,"県":6293,"知":-1528,"私":4231,"税":401,"立":-960,"第":1201,"米":7767,"系":3066,"約":3663,"級":1384,"統":-4229,"総":1163,"線":1255,"者":6457,"能":725,"自":-2869,"英":785,"見":1044,"調":-562,"財":-733,"費":1777,"車":1835,"軍":1375,"込":-1504,"通":-1136,"選":-681,"郎":1026,"郡":4404,"部":1200,"金":2163,"長":421,"開":-1432,"間":1302,"関":-1282,"雨":2009,"電":-1045,"非":2066,"駅":1620,"":-800,"」":2670,"・":-3794,"ッ":-1350,"ア":551,"グ":1319,"ス":874,"ト":521,"ム":1109,"ル":1591,"ロ":2201,"ン":278};
this.UW4__ = {",":3930,".":3508,"―":-4841,"、":3930,"。":3508,"":4999,"「":1895,"」":3798,"〓":-5156,"あ":4752,"い":-3435,"う":-640,"え":-2514,"お":2405,"か":530,"が":6006,"き":-4482,"ぎ":-3821,"く":-3788,"け":-4376,"げ":-4734,"こ":2255,"ご":1979,"さ":2864,"し":-843,"じ":-2506,"す":-731,"ず":1251,"せ":181,"そ":4091,"た":5034,"だ":5408,"ち":-3654,"っ":-5882,"つ":-1659,"て":3994,"で":7410,"と":4547,"な":5433,"に":6499,"ぬ":1853,"ね":1413,"の":7396,"は":8578,"ば":1940,"ひ":4249,"び":-4134,"ふ":1345,"へ":6665,"べ":-744,"ほ":1464,"ま":1051,"み":-2082,"む":-882,"め":-5046,"も":4169,"ゃ":-2666,"や":2795,"ょ":-1544,"よ":3351,"ら":-2922,"り":-9726,"る":-14896,"れ":-2613,"ろ":-4570,"わ":-1783,"を":13150,"ん":-2352,"カ":2145,"コ":1789,"セ":1287,"ッ":-724,"ト":-403,"メ":-1635,"ラ":-881,"リ":-541,"ル":-856,"ン":-3637,"・":-4371,"ー":-11870,"一":-2069,"中":2210,"予":782,"事":-190,"井":-1768,"人":1036,"以":544,"会":950,"体":-1286,"作":530,"側":4292,"先":601,"党":-2006,"共":-1212,"内":584,"円":788,"初":1347,"前":1623,"副":3879,"力":-302,"動":-740,"務":-2715,"化":776,"区":4517,"協":1013,"参":1555,"合":-1834,"和":-681,"員":-910,"器":-851,"回":1500,"国":-619,"園":-1200,"地":866,"場":-1410,"塁":-2094,"士":-1413,"多":1067,"大":571,"子":-4802,"学":-1397,"定":-1057,"寺":-809,"小":1910,"屋":-1328,"山":-1500,"島":-2056,"川":-2667,"市":2771,"年":374,"庁":-4556,"後":456,"性":553,"感":916,"所":-1566,"支":856,"改":787,"政":2182,"教":704,"文":522,"方":-856,"日":1798,"時":1829,"最":845,"月":-9066,"木":-485,"来":-442,"校":-360,"業":-1043,"氏":5388,"民":-2716,"気":-910,"沢":-939,"済":-543,"物":-735,"率":672,"球":-1267,"生":-1286,"産":-1101,"田":-2900,"町":1826,"的":2586,"目":922,"省":-3485,"県":2997,"空":-867,"立":-2112,"第":788,"米":2937,"系":786,"約":2171,"経":1146,"統":-1169,"総":940,"線":-994,"署":749,"者":2145,"能":-730,"般":-852,"行":-792,"規":792,"警":-1184,"議":-244,"谷":-1000,"賞":730,"車":-1481,"軍":1158,"輪":-1433,"込":-3370,"近":929,"道":-1291,"選":2596,"郎":-4866,"都":1192,"野":-1100,"銀":-2213,"長":357,"間":-2344,"院":-2297,"際":-2604,"電":-878,"領":-1659,"題":-792,"館":-1984,"首":1749,"高":2120,"「":1895,"」":3798,"・":-4371,"ッ":-724,"ー":-11870,"カ":2145,"コ":1789,"セ":1287,"ト":-403,"メ":-1635,"ラ":-881,"リ":-541,"ル":-856,"ン":-3637};
this.UW5__ = {",":465,".":-299,"1":-514,"E2":-32768,"]":-2762,"、":465,"。":-299,"「":363,"あ":1655,"い":331,"う":-503,"え":1199,"お":527,"か":647,"が":-421,"き":1624,"ぎ":1971,"く":312,"げ":-983,"さ":-1537,"し":-1371,"す":-852,"だ":-1186,"ち":1093,"っ":52,"つ":921,"て":-18,"で":-850,"と":-127,"ど":1682,"な":-787,"に":-1224,"の":-635,"は":-578,"べ":1001,"み":502,"め":865,"ゃ":3350,"ょ":854,"り":-208,"る":429,"れ":504,"わ":419,"を":-1264,"ん":327,"イ":241,"ル":451,"ン":-343,"中":-871,"京":722,"会":-1153,"党":-654,"務":3519,"区":-901,"告":848,"員":2104,"大":-1296,"学":-548,"定":1785,"嵐":-1304,"市":-2991,"席":921,"年":1763,"思":872,"所":-814,"挙":1618,"新":-1682,"日":218,"月":-4353,"査":932,"格":1356,"機":-1508,"氏":-1347,"田":240,"町":-3912,"的":-3149,"相":1319,"省":-1052,"県":-4003,"研":-997,"社":-278,"空":-813,"統":1955,"者":-2233,"表":663,"語":-1073,"議":1219,"選":-1018,"郎":-368,"長":786,"間":1191,"題":2368,"館":-689,"":-514,"E2":-32768,"「":363,"イ":241,"ル":451,"ン":-343};
this.UW6__ = {",":227,".":808,"1":-270,"E1":306,"、":227,"。":808,"あ":-307,"う":189,"か":241,"が":-73,"く":-121,"こ":-200,"じ":1782,"す":383,"た":-428,"っ":573,"て":-1014,"で":101,"と":-105,"な":-253,"に":-149,"の":-417,"は":-236,"も":-206,"り":187,"る":-135,"を":195,"ル":-673,"ン":-496,"一":-277,"中":201,"件":-800,"会":624,"前":302,"区":1792,"員":-1212,"委":798,"学":-960,"市":887,"広":-695,"後":535,"業":-697,"相":753,"社":-507,"福":974,"空":-822,"者":1811,"連":463,"郎":1082,"":-270,"E1":306,"ル":-673,"ン":-496};
return this;
}
TinySegmenter.prototype.ctype_ = function(str) {
for (var i in this.chartype_) {
if (str.match(this.chartype_[i][0])) {
return this.chartype_[i][1];
}
}
return "O";
}
TinySegmenter.prototype.ts_ = function(v) {
if (v) { return v; }
return 0;
}
TinySegmenter.prototype.segment = function(input) {
if (input == null || input == undefined || input == "") {
return [];
}
var result = [];
var seg = ["B3","B2","B1"];
var ctype = ["O","O","O"];
var o = input.split("");
for (i = 0; i < o.length; ++i) {
seg.push(o[i]);
ctype.push(this.ctype_(o[i]))
}
seg.push("E1");
seg.push("E2");
seg.push("E3");
ctype.push("O");
ctype.push("O");
ctype.push("O");
var word = seg[3];
var p1 = "U";
var p2 = "U";
var p3 = "U";
for (var i = 4; i < seg.length - 3; ++i) {
var score = this.BIAS__;
var w1 = seg[i-3];
var w2 = seg[i-2];
var w3 = seg[i-1];
var w4 = seg[i];
var w5 = seg[i+1];
var w6 = seg[i+2];
var c1 = ctype[i-3];
var c2 = ctype[i-2];
var c3 = ctype[i-1];
var c4 = ctype[i];
var c5 = ctype[i+1];
var c6 = ctype[i+2];
score += this.ts_(this.UP1__[p1]);
score += this.ts_(this.UP2__[p2]);
score += this.ts_(this.UP3__[p3]);
score += this.ts_(this.BP1__[p1 + p2]);
score += this.ts_(this.BP2__[p2 + p3]);
score += this.ts_(this.UW1__[w1]);
score += this.ts_(this.UW2__[w2]);
score += this.ts_(this.UW3__[w3]);
score += this.ts_(this.UW4__[w4]);
score += this.ts_(this.UW5__[w5]);
score += this.ts_(this.UW6__[w6]);
score += this.ts_(this.BW1__[w2 + w3]);
score += this.ts_(this.BW2__[w3 + w4]);
score += this.ts_(this.BW3__[w4 + w5]);
score += this.ts_(this.TW1__[w1 + w2 + w3]);
score += this.ts_(this.TW2__[w2 + w3 + w4]);
score += this.ts_(this.TW3__[w3 + w4 + w5]);
score += this.ts_(this.TW4__[w4 + w5 + w6]);
score += this.ts_(this.UC1__[c1]);
score += this.ts_(this.UC2__[c2]);
score += this.ts_(this.UC3__[c3]);
score += this.ts_(this.UC4__[c4]);
score += this.ts_(this.UC5__[c5]);
score += this.ts_(this.UC6__[c6]);
score += this.ts_(this.BC1__[c2 + c3]);
score += this.ts_(this.BC2__[c3 + c4]);
score += this.ts_(this.BC3__[c4 + c5]);
score += this.ts_(this.TC1__[c1 + c2 + c3]);
score += this.ts_(this.TC2__[c2 + c3 + c4]);
score += this.ts_(this.TC3__[c3 + c4 + c5]);
score += this.ts_(this.TC4__[c4 + c5 + c6]);
// score += this.ts_(this.TC5__[c4 + c5 + c6]);
score += this.ts_(this.UQ1__[p1 + c1]);
score += this.ts_(this.UQ2__[p2 + c2]);
score += this.ts_(this.UQ3__[p3 + c3]);
score += this.ts_(this.BQ1__[p2 + c2 + c3]);
score += this.ts_(this.BQ2__[p2 + c3 + c4]);
score += this.ts_(this.BQ3__[p3 + c2 + c3]);
score += this.ts_(this.BQ4__[p3 + c3 + c4]);
score += this.ts_(this.TQ1__[p2 + c1 + c2 + c3]);
score += this.ts_(this.TQ2__[p2 + c2 + c3 + c4]);
score += this.ts_(this.TQ3__[p3 + c1 + c2 + c3]);
score += this.ts_(this.TQ4__[p3 + c2 + c3 + c4]);
var p = "O";
if (score > 0) {
result.push(word);
word = "";
p = "B";
}
p1 = p2;
p2 = p3;
p3 = p;
word += seg[i];
}
result.push(word);
return result;
}
lunr.TinySegmenter = TinySegmenter;
};
}));
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
{"version":3,"sources":["src/templates/assets/stylesheets/palette/_scheme.scss","../../../../src/templates/assets/stylesheets/palette.scss","src/templates/assets/stylesheets/palette/_accent.scss","src/templates/assets/stylesheets/palette/_primary.scss","src/templates/assets/stylesheets/utilities/_break.scss"],"names":[],"mappings":"AA2BA,cAGE,6BAME,sDAAA,CACA,6DAAA,CACA,+DAAA,CACA,gEAAA,CACA,mDAAA,CACA,6DAAA,CACA,+DAAA,CACA,gEAAA,CAGA,mDAAA,CACA,gDAAA,CACA,yDAAA,CACA,4DAAA,CAGA,0BAAA,CACA,mCAAA,CAGA,iCAAA,CACA,kCAAA,CACA,mCAAA,CACA,mCAAA,CACA,kCAAA,CACA,iCAAA,CACA,+CAAA,CACA,6DAAA,CACA,gEAAA,CACA,4DAAA,CACA,4DAAA,CACA,6DAAA,CAGA,6CAAA,CAGA,+CAAA,CAGA,uDAAA,CACA,6DAAA,CACA,2DAAA,CAGA,iCAAA,CAGA,yDAAA,CACA,iEAAA,CAGA,mDAAA,CACA,mDAAA,CAGA,qDAAA,CACA,uDAAA,CAGA,8DAAA,CAKA,8DAAA,CAKA,0DAAA,CAzEA,iBCiBF,CD6DE,kHAEE,YC3DJ,CDkFE,yDACE,4BChFJ,CD+EE,2DACE,4BC7EJ,CD4EE,gEACE,4BC1EJ,CDyEE,2DACE,4BCvEJ,CDsEE,yDACE,4BCpEJ,CDmEE,0DACE,4BCjEJ,CDgEE,gEACE,4BC9DJ,CD6DE,0DACE,4BC3DJ,CD0DE,2OACE,4BC/CJ,CDsDA,+FAGE,iCCpDF,CACF,CCjDE,2BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD6CN,CCvDE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDoDN,CC9DE,8BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD2DN,CCrEE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDkEN,CC5EE,8BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDyEN,CCnFE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDgFN,CC1FE,kCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDuFN,CCjGE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD8FN,CCxGE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDqGN,CC/GE,6BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD4GN,CCtHE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDmHN,CC7HE,4BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCD6HN,CCpIE,8BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCDoIN,CC3IE,6BACE,yBAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCD2IN,CClJE,8BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCDkJN,CCzJE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDsJN,CE3JE,4BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwJN,CEnKE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgKN,CE3KE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwKN,CEnLE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgLN,CE3LE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwLN,CEnME,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgMN,CE3ME,mCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwMN,CEnNE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgNN,CE3NE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwNN,CEnOE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgON,CE3OE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwON,CEnPE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFmPN,CE3PE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCF2PN,CEnQE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFmQN,CE3QE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCF2QN,CEnRE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgRN,CE3RE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwRN,CEnSE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCAAA,CAKA,4BF4RN,CE5SE,kCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCAAA,CAKA,4BFqSN,CEtRE,sEACE,4BFyRJ,CE1RE,+DACE,4BF6RJ,CE9RE,iEACE,4BFiSJ,CElSE,gEACE,4BFqSJ,CEtSE,iEACE,4BFySJ,CEhSA,8BACE,mDAAA,CACA,4DAAA,CACA,0DAAA,CACA,oDAAA,CACA,2DAAA,CAGA,4BFiSF,CE9RE,yCACE,+BFgSJ,CE7RI,kDAEE,0CAAA,CACA,sCAAA,CAFA,mCFiSN,CG7MI,mCD1EA,+CACE,8CF0RJ,CEvRI,qDACE,8CFyRN,CEpRE,iEACE,mCFsRJ,CACF,CGxNI,sCDvDA,uCACE,oCFkRJ,CACF,CEzQA,8BACE,kDAAA,CACA,4DAAA,CACA,wDAAA,CACA,oDAAA,CACA,6DAAA,CAGA,4BF0QF,CEvQE,yCACE,+BFyQJ,CEtQI,kDAEE,0CAAA,CACA,sCAAA,CAFA,mCF0QN,CEnQE,yCACE,6CFqQJ,CG9NI,0CDhCA,8CACE,gDFiQJ,CACF,CGnOI,0CDvBA,iFACE,6CF6PJ,CACF,CG3PI,sCDKA,uCACE,6CFyPJ,CACF","file":"palette.css"}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-2300
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More