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 }