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
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:
@@ -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
@@ -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"]
|
||||
@@ -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' })
|
||||
});
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 +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-Za-zA-Z0-90-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 +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 +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-Za-zA-Z0-90-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 +0,0 @@
|
||||
module.exports=require("./lunr.ja");
|
||||
@@ -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
@@ -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 +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,"")}}}}});
|
||||
@@ -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 +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-Za-zA-Z0-90-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 +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 +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 +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 +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-Za-zA-Z]":"A",
|
||||
"[0-90-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,"1":-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,"1":-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,"1":-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
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
Reference in New Issue
Block a user