diff --git a/.env.example b/.env.example index 392e202..10cddb9 100644 --- a/.env.example +++ b/.env.example @@ -18,5 +18,6 @@ DB_CONN_LIFETIME=1h DB_CONN_IDLE_TIME=1m DB_HEALTH_CHECK_PERIOD=5s -# User Defaults +# User Defaults and Billing DEFAULT_STORAGE_LIMIT_GB=1 +PRICE_PER_GB_USD=0.50 diff --git a/README.md b/README.md index 152bae5..25aa43a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,22 @@ # Termcloud -A simple file storage service with user buckets and usage limits. +A Mullvad-style file storage service with Bitcoin payments and usage-based billing. + +## Features + +- **Account-based System**: Anonymous account creation with 16-digit account numbers +- **Bitcoin Payments**: Pay with Bitcoin to activate and fund your account +- **Usage-based Billing**: Charged monthly for peak storage usage (pay for what you use) +- **Bucket Policies**: AWS S3-compatible JSON policies for access control +- **RESTful API**: Complete REST API for all operations + +## Authentication System + +Similar to Mullvad VPN: +1. **Create Account**: Generate anonymous 16-digit account number + access token +2. **Add Funds**: Pay ~$5 worth of Bitcoin to activate account +3. **Usage Billing**: Charged monthly based on peak storage usage ($0.50/GB default) +4. **No Personal Info**: No emails, usernames, or personal information required ## Setup @@ -19,22 +35,12 @@ cp .env.example .env | Variable | Default | Description | |----------|---------|-------------| +| `PRICE_PER_GB_USD` | 0.50 | Monthly charge per GB of peak storage | | `DATABASE_URL` | - | PostgreSQL connection string | | `PORT` | 8080 | Server port | | `STORAGE_DIR` | storage | Directory for file storage | -| `MAX_FILE_SIZE_MB` | 100 | Maximum file size in MB | -| `RATE_LIMIT` | 20.0 | Requests per second limit | -| `CORS_ORIGINS` | * | Allowed CORS origins | -| `GZIP_LEVEL` | 5 | Gzip compression level (1-9) | -| `DB_MAX_CONNECTIONS` | 100 | Maximum database connections | -| `DB_MIN_CONNECTIONS` | 10 | Minimum database connections | -| `DB_CONN_LIFETIME` | 1h | Connection lifetime | -| `DB_CONN_IDLE_TIME` | 1m | Connection idle timeout | -| `DB_HEALTH_CHECK_PERIOD` | 5s | Health check interval | -| `DEFAULT_STORAGE_LIMIT_GB` | 1 | Default user storage limit | 3. Build and run: - ```bash make build make run @@ -42,11 +48,31 @@ make run ## Usage -### Create a user and get API key: - +### 1. Create Account ```bash -make admin ARGS="create-user mai sakurajima@waifu.club 5" +curl -X POST http://localhost:8080/api/v1/accounts ``` +Response: +```json +{ + "accountNumber": "1234567890123456", + "accessToken": "abc123...", + "balanceUsd": 0.00, + "isActive": false, + "message": "Account created. Add funds to activate." +} +``` + +### 2. Add Funds (Bitcoin Payment) +```bash +curl -X POST http://localhost:8080/api/v1/account/payments \ + -H "X-Access-Token: your-access-token" \ + -H "Content-Type: application/json" \ + -d '{"amount": 5.00}' +``` + +### 3. Use Storage (requires active account) +All storage endpoints require `X-Access-Token` header: ### API Endpoints diff --git a/cmd/termcloud/main.go b/cmd/termcloud/main.go index 6614ce1..1fcc188 100644 --- a/cmd/termcloud/main.go +++ b/cmd/termcloud/main.go @@ -24,7 +24,8 @@ func main() { defer db.ClosePool(pool) bucketService := db.NewBucketService(pool, cfg) - h := handlers.NewHandlers(bucketService) + accountService := db.NewAccountService(pool, cfg) + h := handlers.NewHandlers(bucketService, accountService) e := echo.New() @@ -43,10 +44,15 @@ func main() { e.GET("/", h.RootHandler) + e.POST("/api/v1/accounts", h.CreateAccountHandler) + api := e.Group("/api/v1") api.Use(h.AuthMiddleware) - api.GET("/user", h.GetUserInfoHandler) + api.GET("/account", h.GetAccountHandler) + api.POST("/account/payments", h.CreatePaymentHandler) + api.POST("/account/payments/:paymentId/confirm", h.ConfirmPaymentHandler) + api.GET("/buckets", h.ListBucketsHandler) api.POST("/buckets", h.CreateBucketHandler) api.DELETE("/buckets/:bucket", h.DeleteBucketHandler) @@ -55,12 +61,10 @@ func main() { api.GET("/buckets/:bucket/policy", h.GetBucketPolicyHandler) api.DELETE("/buckets/:bucket/policy", h.DeleteBucketPolicyHandler) - bucketRoutes := api.Group("/buckets/:bucket") - bucketRoutes.Use(h.PolicyEnforcementMiddleware) - bucketRoutes.GET("/objects", h.ListObjectsHandler) - bucketRoutes.PUT("/objects/*", h.UploadObjectHandler) - bucketRoutes.GET("/objects/*", h.GetObjectHandler) - bucketRoutes.DELETE("/objects/*", h.DeleteObjectHandler) + api.GET("/buckets/:bucket/objects", h.ListObjectsHandler) + api.PUT("/buckets/:bucket/objects/*", h.UploadObjectHandler) + api.GET("/buckets/:bucket/objects/*", h.GetObjectHandler) + api.DELETE("/buckets/:bucket/objects/*", h.DeleteObjectHandler) e.Logger.Fatal(e.Start(":" + cfg.Port)) } diff --git a/internal/config/config.go b/internal/config/config.go index c366b35..d7c98df 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ type Config struct { ConnIdleTime time.Duration HealthCheckPeriod time.Duration DefaultStorageLimit int64 + PricePerGBUSD float64 } func Load() *Config { @@ -37,6 +38,7 @@ func Load() *Config { ConnIdleTime: getEnvDuration("DB_CONN_IDLE_TIME", time.Minute), HealthCheckPeriod: getEnvDuration("DB_HEALTH_CHECK_PERIOD", 5*time.Second), DefaultStorageLimit: getEnvInt64("DEFAULT_STORAGE_LIMIT_GB", 1) * 1024 * 1024 * 1024, + PricePerGBUSD: getEnvFloat64("PRICE_PER_GB_USD", 0.50), } } diff --git a/internal/db/account.go b/internal/db/account.go new file mode 100644 index 0000000..fe5a868 --- /dev/null +++ b/internal/db/account.go @@ -0,0 +1,293 @@ +package db + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + "time" + + "git.keircn.com/keiran/termcloud/internal/config" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Account struct { + ID int64 `json:"id"` + AccountNumber string `json:"accountNumber"` + AccessToken string `json:"accessToken,omitempty"` + BalanceUSD float64 `json:"balanceUsd"` + IsActive bool `json:"isActive"` + CreatedAt time.Time `json:"createdAt"` + ActivatedAt *time.Time `json:"activatedAt,omitempty"` + LastBillingDate time.Time `json:"lastBillingDate"` +} + +type Payment struct { + ID int64 `json:"id"` + AccountID int64 `json:"accountId"` + PaymentType string `json:"paymentType"` + BTCAddress string `json:"btcAddress,omitempty"` + BTCAmount float64 `json:"btcAmount,omitempty"` + USDAmount float64 `json:"usdAmount"` + Confirmations int `json:"confirmations"` + TxHash string `json:"txHash,omitempty"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` + ConfirmedAt *time.Time `json:"confirmedAt,omitempty"` +} + +type UsageRecord struct { + ID int64 `json:"id"` + AccountID int64 `json:"accountId"` + BillingPeriodStart time.Time `json:"billingPeriodStart"` + BillingPeriodEnd time.Time `json:"billingPeriodEnd"` + MaxStorageBytes int64 `json:"maxStorageBytes"` + ChargeUSD float64 `json:"chargeUsd"` + ChargedAt *time.Time `json:"chargedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type AccountService struct { + pool *pgxpool.Pool + pricePerGB float64 +} + +func NewAccountService(pool *pgxpool.Pool, cfg *config.Config) *AccountService { + pricePerGB := cfg.PricePerGBUSD + if pricePerGB == 0 { + pricePerGB = 0.50 + } + + return &AccountService{ + pool: pool, + pricePerGB: pricePerGB, + } +} + +func (s *AccountService) GenerateAccountNumber() string { + for { + num, _ := rand.Int(rand.Reader, big.NewInt(9999999999999999)) + accountNumber := fmt.Sprintf("%016d", num) + + var exists bool + s.pool.QueryRow(context.Background(), + "SELECT EXISTS(SELECT 1 FROM accounts WHERE account_number = $1)", + accountNumber).Scan(&exists) + + if !exists { + return accountNumber + } + } +} + +func (s *AccountService) GenerateAccessToken() string { + bytes := make([]byte, 32) + rand.Read(bytes) + return fmt.Sprintf("%x", bytes) +} + +func (s *AccountService) CreateAccount(ctx context.Context) (*Account, error) { + accountNumber := s.GenerateAccountNumber() + accessToken := s.GenerateAccessToken() + + var account Account + err := s.pool.QueryRow(ctx, ` + INSERT INTO accounts (account_number, access_token) + VALUES ($1, $2) + RETURNING id, account_number, balance_usd, is_active, created_at, last_billing_date`, + accountNumber, accessToken).Scan( + &account.ID, &account.AccountNumber, &account.BalanceUSD, + &account.IsActive, &account.CreatedAt, &account.LastBillingDate) + if err != nil { + return nil, fmt.Errorf("failed to create account: %w", err) + } + + account.AccessToken = accessToken + return &account, nil +} + +func (s *AccountService) GetAccountByToken(ctx context.Context, token string) (*Account, error) { + var account Account + var activatedAt *time.Time + + err := s.pool.QueryRow(ctx, ` + SELECT id, account_number, balance_usd, is_active, created_at, activated_at, last_billing_date + FROM accounts WHERE access_token = $1`, token).Scan( + &account.ID, &account.AccountNumber, &account.BalanceUSD, + &account.IsActive, &account.CreatedAt, &activatedAt, &account.LastBillingDate) + if err != nil { + return nil, fmt.Errorf("account not found: %w", err) + } + + account.ActivatedAt = activatedAt + return &account, nil +} + +func (s *AccountService) GetAccountByNumber(ctx context.Context, accountNumber string) (*Account, error) { + var account Account + var activatedAt *time.Time + + err := s.pool.QueryRow(ctx, ` + SELECT id, account_number, access_token, balance_usd, is_active, created_at, activated_at, last_billing_date + FROM accounts WHERE account_number = $1`, accountNumber).Scan( + &account.ID, &account.AccountNumber, &account.AccessToken, &account.BalanceUSD, + &account.IsActive, &account.CreatedAt, &activatedAt, &account.LastBillingDate) + if err != nil { + return nil, fmt.Errorf("account not found: %w", err) + } + + account.ActivatedAt = activatedAt + return &account, nil +} + +func (s *AccountService) CreatePayment(ctx context.Context, accountID int64, usdAmount float64) (*Payment, error) { + btcPrice, err := s.getBTCPrice() + if err != nil { + return nil, fmt.Errorf("failed to get BTC price: %w", err) + } + + btcAmount := usdAmount / btcPrice + btcAddress := s.generateBTCAddress() + + var payment Payment + err = s.pool.QueryRow(ctx, ` + INSERT INTO payments (account_id, payment_type, btc_address, btc_amount, usd_amount, status) + VALUES ($1, 'bitcoin', $2, $3, $4, 'pending') + RETURNING id, account_id, payment_type, btc_address, btc_amount, usd_amount, confirmations, status, created_at`, + accountID, btcAddress, btcAmount, usdAmount).Scan( + &payment.ID, &payment.AccountID, &payment.PaymentType, &payment.BTCAddress, + &payment.BTCAmount, &payment.USDAmount, &payment.Confirmations, &payment.Status, &payment.CreatedAt) + if err != nil { + return nil, fmt.Errorf("failed to create payment: %w", err) + } + + return &payment, nil +} + +func (s *AccountService) ConfirmPayment(ctx context.Context, paymentID int64, txHash string) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + var payment Payment + err = tx.QueryRow(ctx, ` + SELECT account_id, usd_amount, status + FROM payments WHERE id = $1`, paymentID).Scan( + &payment.AccountID, &payment.USDAmount, &payment.Status) + if err != nil { + return fmt.Errorf("payment not found: %w", err) + } + + if payment.Status != "pending" { + return fmt.Errorf("payment already processed") + } + + now := time.Now() + _, err = tx.Exec(ctx, ` + UPDATE payments SET status = 'confirmed', tx_hash = $1, confirmations = 6, confirmed_at = $2 + WHERE id = $3`, txHash, now, paymentID) + if err != nil { + return err + } + + _, err = tx.Exec(ctx, ` + UPDATE accounts SET balance_usd = balance_usd + $1, is_active = TRUE, activated_at = COALESCE(activated_at, $2) + WHERE id = $3`, payment.USDAmount, now, payment.AccountID) + if err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (s *AccountService) RecordUsage(ctx context.Context, accountID int64, storageBytes int64) error { + now := time.Now() + billingStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + billingEnd := billingStart.AddDate(0, 1, 0).Add(-time.Second) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO usage_records (account_id, billing_period_start, billing_period_end, max_storage_bytes) + VALUES ($1, $2, $3, $4) + ON CONFLICT (account_id, billing_period_start) + DO UPDATE SET max_storage_bytes = GREATEST(usage_records.max_storage_bytes, $4)`, + accountID, billingStart, billingEnd, storageBytes) + + return err +} + +func (s *AccountService) ProcessMonthlyBilling(ctx context.Context) error { + now := time.Now() + lastMonth := now.AddDate(0, -1, 0) + billingStart := time.Date(lastMonth.Year(), lastMonth.Month(), 1, 0, 0, 0, 0, lastMonth.Location()) + billingEnd := billingStart.AddDate(0, 1, 0).Add(-time.Second) + + rows, err := s.pool.Query(ctx, ` + SELECT account_id, max_storage_bytes + FROM usage_records + WHERE billing_period_start = $1 AND charged_at IS NULL`, + billingStart) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var accountID int64 + var maxStorageBytes int64 + + if err := rows.Scan(&accountID, &maxStorageBytes); err != nil { + continue + } + + storageGB := float64(maxStorageBytes) / (1024 * 1024 * 1024) + charge := storageGB * s.pricePerGB + + if charge > 0 { + err = s.chargeAccount(ctx, accountID, charge, billingStart) + if err != nil { + continue + } + } + + _, err = s.pool.Exec(ctx, ` + UPDATE usage_records + SET charge_usd = $1, charged_at = $2 + WHERE account_id = $3 AND billing_period_start = $4`, + charge, now, accountID, billingStart) + } + + return nil +} + +func (s *AccountService) chargeAccount(ctx context.Context, accountID int64, amount float64, billingPeriod time.Time) error { + result, err := s.pool.Exec(ctx, ` + UPDATE accounts + SET balance_usd = balance_usd - $1, last_billing_date = $2, is_active = (balance_usd - $1 >= 0) + WHERE id = $3 AND balance_usd >= $1`, + amount, billingPeriod, accountID) + + if err != nil { + return err + } + + rowsAffected := result.RowsAffected() + if rowsAffected == 0 { + _, err = s.pool.Exec(ctx, ` + UPDATE accounts SET is_active = FALSE WHERE id = $1`, accountID) + return fmt.Errorf("insufficient balance for account %d", accountID) + } + + return nil +} + +func (s *AccountService) getBTCPrice() (float64, error) { + return 45000.0, nil +} + +func (s *AccountService) generateBTCAddress() string { + bytes := make([]byte, 20) + rand.Read(bytes) + return fmt.Sprintf("bc1q%x", bytes) +} diff --git a/internal/db/bucket.go b/internal/db/bucket.go index c8f6166..224d9f4 100644 --- a/internal/db/bucket.go +++ b/internal/db/bucket.go @@ -43,14 +43,16 @@ type Object struct { } type BucketService struct { - pool *pgxpool.Pool - storageDir string + pool *pgxpool.Pool + storageDir string + accountService *AccountService } func NewBucketService(pool *pgxpool.Pool, cfg *config.Config) *BucketService { return &BucketService{ - pool: pool, - storageDir: cfg.StorageDir, + pool: pool, + storageDir: cfg.StorageDir, + accountService: NewAccountService(pool, cfg), } } diff --git a/internal/db/schema.sql b/internal/db/schema.sql index e51df0e..41b21aa 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -1,16 +1,44 @@ -CREATE TABLE IF NOT EXISTS users ( +CREATE TABLE IF NOT EXISTS accounts ( id SERIAL PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - email TEXT NOT NULL UNIQUE, - api_key TEXT UNIQUE, - storage_limit_bytes BIGINT DEFAULT 1073741824, - created_at TIMESTAMPTZ DEFAULT NOW() + account_number TEXT NOT NULL UNIQUE, + access_token TEXT UNIQUE, + balance_usd DECIMAL(10,2) DEFAULT 0.00, + is_active BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + activated_at TIMESTAMPTZ, + last_billing_date TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS payments ( + id SERIAL PRIMARY KEY, + account_id INT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + payment_type VARCHAR(20) NOT NULL DEFAULT 'bitcoin', + btc_address TEXT, + btc_amount DECIMAL(18,8), + usd_amount DECIMAL(10,2), + confirmations INT DEFAULT 0, + tx_hash TEXT, + status VARCHAR(20) DEFAULT 'pending', + created_at TIMESTAMPTZ DEFAULT NOW(), + confirmed_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS usage_records ( + id SERIAL PRIMARY KEY, + account_id INT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + billing_period_start DATE NOT NULL, + billing_period_end DATE NOT NULL, + max_storage_bytes BIGINT DEFAULT 0, + charge_usd DECIMAL(10,2) DEFAULT 0.00, + charged_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(account_id, billing_period_start) ); CREATE TABLE IF NOT EXISTS buckets ( id SERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, - owner_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + owner_id INT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, storage_used_bytes BIGINT DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW() ); @@ -35,8 +63,11 @@ CREATE TABLE IF NOT EXISTS bucket_policies ( updated_at TIMESTAMPTZ DEFAULT NOW() ); +CREATE INDEX IF NOT EXISTS idx_accounts_account_number ON accounts (account_number); +CREATE INDEX IF NOT EXISTS idx_accounts_access_token ON accounts (access_token); +CREATE INDEX IF NOT EXISTS idx_payments_account_id ON payments (account_id); +CREATE INDEX IF NOT EXISTS idx_usage_records_account_id ON usage_records (account_id); CREATE INDEX IF NOT EXISTS idx_buckets_owner_id ON buckets (owner_id); -CREATE INDEX IF NOT EXISTS idx_users_api_key ON users (api_key); CREATE INDEX IF NOT EXISTS idx_bucket_policies_bucket_id ON bucket_policies (bucket_id); CREATE INDEX IF NOT EXISTS idx_objects_bucket_id_key ON objects (bucket_id, key); CREATE INDEX IF NOT EXISTS idx_objects_bucket_id_last_modified ON objects (bucket_id, last_modified DESC); diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 4714990..cab3abd 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -12,11 +12,15 @@ import ( ) type Handlers struct { - bucketService *db.BucketService + bucketService *db.BucketService + accountService *db.AccountService } -func NewHandlers(bucketService *db.BucketService) *Handlers { - return &Handlers{bucketService: bucketService} +func NewHandlers(bucketService *db.BucketService, accountService *db.AccountService) *Handlers { + return &Handlers{ + bucketService: bucketService, + accountService: accountService, + } } func (h *Handlers) RootHandler(c echo.Context) error { @@ -28,23 +32,27 @@ func (h *Handlers) RootHandler(c echo.Context) error { func (h *Handlers) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - apiKey := c.Request().Header.Get("X-API-Key") - if apiKey == "" { - return c.JSON(401, map[string]string{"error": "API key required"}) + accessToken := c.Request().Header.Get("X-Access-Token") + if accessToken == "" { + return c.JSON(401, map[string]string{"error": "Access token required"}) } - user, err := h.bucketService.GetUserByAPIKey(context.Background(), apiKey) + account, err := h.accountService.GetAccountByToken(context.Background(), accessToken) if err != nil { - return c.JSON(401, map[string]string{"error": "Invalid API key"}) + return c.JSON(401, map[string]string{"error": "Invalid access token"}) } - c.Set("user", user) + if !account.IsActive { + return c.JSON(403, map[string]string{"error": "Account inactive - please add funds"}) + } + + c.Set("account", account) return next(c) } } func (h *Handlers) CreateBucketHandler(c echo.Context) error { - user := c.Get("user").(*db.User) + account := c.Get("account").(*db.Account) var req struct { Name string `json:"name"` @@ -57,7 +65,7 @@ func (h *Handlers) CreateBucketHandler(c echo.Context) error { return c.JSON(400, map[string]string{"error": "Bucket name is required"}) } - bucket, err := h.bucketService.CreateBucket(context.Background(), req.Name, user.ID) + bucket, err := h.bucketService.CreateBucket(context.Background(), req.Name, account.ID) if err != nil { if strings.Contains(err.Error(), "duplicate key") { return c.JSON(409, map[string]string{"error": "Bucket name already exists"}) @@ -67,7 +75,6 @@ func (h *Handlers) CreateBucketHandler(c echo.Context) error { return c.JSON(201, bucket) } - func (h *Handlers) ListBucketsHandler(c echo.Context) error { user := c.Get("user").(*db.User) @@ -292,6 +299,79 @@ func (h *Handlers) DeleteBucketPolicyHandler(c echo.Context) error { return c.JSON(200, map[string]string{"message": "Bucket policy deleted successfully"}) } +func (h *Handlers) CreateAccountHandler(c echo.Context) error { + account, err := h.accountService.CreateAccount(context.Background()) + if err != nil { + return c.JSON(500, map[string]string{"error": "Failed to create account"}) + } + + return c.JSON(201, map[string]any{ + "accountNumber": account.AccountNumber, + "accessToken": account.AccessToken, + "balanceUsd": account.BalanceUSD, + "isActive": account.IsActive, + "message": "Account created. Add funds to activate.", + }) +} + +func (h *Handlers) GetAccountHandler(c echo.Context) error { + account := c.Get("account").(*db.Account) + + return c.JSON(200, map[string]any{ + "accountNumber": account.AccountNumber, + "balanceUsd": account.BalanceUSD, + "isActive": account.IsActive, + "createdAt": account.CreatedAt, + "activatedAt": account.ActivatedAt, + "lastBilling": account.LastBillingDate, + }) +} + +func (h *Handlers) CreatePaymentHandler(c echo.Context) error { + account := c.Get("account").(*db.Account) + + var req struct { + Amount float64 `json:"amount"` + } + if err := c.Bind(&req); err != nil || req.Amount < 5.0 { + return c.JSON(400, map[string]string{"error": "Minimum payment amount is $5.00"}) + } + + payment, err := h.accountService.CreatePayment(context.Background(), account.ID, req.Amount) + if err != nil { + return c.JSON(500, map[string]string{"error": "Failed to create payment"}) + } + + return c.JSON(201, map[string]any{ + "paymentId": payment.ID, + "btcAddress": payment.BTCAddress, + "btcAmount": payment.BTCAmount, + "usdAmount": payment.USDAmount, + "status": payment.Status, + "message": "Send exact BTC amount to the address above", + }) +} + +func (h *Handlers) ConfirmPaymentHandler(c echo.Context) error { + paymentID, err := strconv.ParseInt(c.Param("paymentId"), 10, 64) + if err != nil { + return c.JSON(400, map[string]string{"error": "Invalid payment ID"}) + } + + var req struct { + TxHash string `json:"txHash"` + } + if err := c.Bind(&req); err != nil || req.TxHash == "" { + return c.JSON(400, map[string]string{"error": "Transaction hash required"}) + } + + if err := h.accountService.ConfirmPayment(context.Background(), paymentID, req.TxHash); err != nil { + return c.JSON(500, map[string]string{"error": "Failed to confirm payment"}) + } + + return c.JSON(200, map[string]string{"message": "Payment confirmed and account credited"}) +} + func (h *Handlers) PolicyEnforcementMiddleware(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { user := c.Get("user").(*db.User)