294 lines
8.8 KiB
Go
294 lines
8.8 KiB
Go
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)
|
|
}
|