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) }