Files
openaccounting-server/database/database.go
Aaron Guise f99a866e13 feat: implement unified S3-compatible storage system
Consolidates storage backends into a single S3-compatible driver that supports:
- AWS S3 (native)
- Backblaze B2 (S3-compatible API)
- Cloudflare R2 (S3-compatible API)
- MinIO and other S3-compatible services
- Local filesystem for development

This replaces the previous separate B2 driver with a unified approach,
reducing dependencies and complexity while adding support for more services.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 23:07:44 +12:00

338 lines
9.5 KiB
Go

package database
import (
"fmt"
"log"
"os"
"time"
"github.com/openaccounting/oa-server/core/util/id"
"github.com/openaccounting/oa-server/models"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var DB *gorm.DB
type Config struct {
Driver string // "mysql" or "sqlite"
Host string
Port string
User string
Password string
DBName string
SSLMode string
// SQLite specific
File string // SQLite database file path
}
func Connect(config *Config) error {
// Configure GORM logger
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: time.Second,
LogLevel: logger.Info,
IgnoreRecordNotFoundError: true,
Colorful: true,
},
)
var db *gorm.DB
var err error
// Choose driver based on config
switch config.Driver {
case "sqlite":
// Use SQLite
dbFile := config.File
if dbFile == "" {
dbFile = "./openaccounting.db" // Default SQLite file
}
db, err = gorm.Open(sqlite.Open(dbFile), &gorm.Config{
Logger: newLogger,
})
case "mysql":
// Use MySQL (existing logic)
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
config.User, config.Password, config.Host, config.Port, config.DBName)
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: newLogger,
})
default:
return fmt.Errorf("unsupported database driver: %s (supported: mysql, sqlite)", config.Driver)
}
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
// Configure connection pool (only for MySQL, SQLite handles this internally)
if config.Driver == "mysql" {
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("failed to get database instance: %w", err)
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(25)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
}
DB = db
return nil
}
// AutoMigrate runs automatic migrations for all models
func AutoMigrate() error {
return DB.AutoMigrate(
&models.Org{},
&models.User{},
&models.UserOrg{},
&models.Token{},
&models.Account{},
&models.Transaction{},
&models.Split{},
&models.Balance{},
&models.Permission{},
&models.Price{},
&models.Session{},
&models.APIKey{},
&models.Invite{},
&models.BudgetItem{},
&models.Attachment{},
)
}
// Migrate runs custom migrations
func Migrate() error {
// Create indexes
if err := createIndexes(); err != nil {
return err
}
// Insert default data - temporarily disabled for testing
// if err := seedDefaultData(); err != nil {
// return err
// }
return nil
}
func createIndexes() error {
// Create custom indexes that GORM doesn't handle automatically
// Based on original indexes.sql file
indexes := []string{
// Original indexes from indexes.sql
"CREATE INDEX IF NOT EXISTS account_orgId_index ON accounts(orgId)",
"CREATE INDEX IF NOT EXISTS split_accountId_index ON splits(accountId)",
"CREATE INDEX IF NOT EXISTS split_transactionId_index ON splits(transactionId)",
"CREATE INDEX IF NOT EXISTS split_date_index ON splits(date)",
"CREATE INDEX IF NOT EXISTS split_updated_index ON splits(updated)",
"CREATE INDEX IF NOT EXISTS budgetitem_orgId_index ON budget_items(orgId)",
"CREATE INDEX IF NOT EXISTS attachment_transactionId_index ON attachment(transactionId)",
"CREATE INDEX IF NOT EXISTS attachment_orgId_index ON attachment(orgId)",
"CREATE INDEX IF NOT EXISTS attachment_userId_index ON attachment(userId)",
"CREATE INDEX IF NOT EXISTS attachment_uploaded_index ON attachment(uploaded)",
// Additional useful indexes for performance
"CREATE INDEX IF NOT EXISTS idx_transaction_date ON transactions(date)",
"CREATE INDEX IF NOT EXISTS idx_transaction_org ON transactions(orgId)",
"CREATE INDEX IF NOT EXISTS idx_account_parent ON accounts(parent)",
"CREATE INDEX IF NOT EXISTS idx_userorg_user ON user_orgs(userId)",
"CREATE INDEX IF NOT EXISTS idx_userorg_org ON user_orgs(orgId)",
"CREATE INDEX IF NOT EXISTS idx_balance_account_date ON balances(accountId, date)",
"CREATE INDEX IF NOT EXISTS idx_permission_org_account ON permissions(orgId, accountId)",
}
for _, idx := range indexes {
if err := DB.Exec(idx).Error; err != nil {
return fmt.Errorf("failed to create index: %w", err)
}
}
return nil
}
func seedDefaultData() error {
// Check if we already have data
var count int64
if err := DB.Model(&models.Org{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil // Data already exists
}
// Create a default organization
defaultOrg := models.Org{
ID: id.New(), // You'll need to implement this
Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Name: "Default Organization",
Currency: "USD",
Precision: 2,
Timezone: "UTC",
}
if err := DB.Create(&defaultOrg).Error; err != nil {
return fmt.Errorf("failed to create default organization: %w", err)
}
// Create default accounts for the organization
defaultAccounts := []models.Account{
{
ID: id.New(),
OrgID: defaultOrg.ID,
Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Name: "Assets",
Parent: []byte{0}, // Root account has zero parent
Currency: "USD",
Precision: 2,
DebitBalance: true,
},
{
ID: id.New(),
OrgID: defaultOrg.ID,
Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Name: "Liabilities",
Parent: []byte{0},
Currency: "USD",
Precision: 2,
DebitBalance: false,
},
{
ID: id.New(),
OrgID: defaultOrg.ID,
Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Name: "Equity",
Parent: []byte{0},
Currency: "USD",
Precision: 2,
DebitBalance: false,
},
{
ID: id.New(),
OrgID: defaultOrg.ID,
Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Name: "Revenue",
Parent: []byte{0},
Currency: "USD",
Precision: 2,
DebitBalance: false,
},
{
ID: id.New(),
OrgID: defaultOrg.ID,
Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Name: "Expenses",
Parent: []byte{0},
Currency: "USD",
Precision: 2,
DebitBalance: true,
},
}
// Create accounts and store their IDs for parent-child relationships
accountMap := make(map[string]*models.Account)
for _, acc := range defaultAccounts {
account := acc
if err := DB.Create(&account).Error; err != nil {
return fmt.Errorf("failed to create account %s: %w", acc.Name, err)
}
accountMap[acc.Name] = &account
}
// Create Current Assets first
var assetsParent []byte
if assetsAccount, exists := accountMap["Assets"]; exists {
assetsParent = assetsAccount.ID
} else {
return fmt.Errorf("Assets account not found in accountMap")
}
currentAssets := models.Account{
ID: id.New(),
OrgID: defaultOrg.ID,
Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Name: "Current Assets",
Parent: assetsParent,
Currency: "USD",
Precision: 2,
DebitBalance: true,
}
if err := DB.Create(&currentAssets).Error; err != nil {
return fmt.Errorf("failed to create Current Assets: %w", err)
}
accountMap["Current Assets"] = &currentAssets
// Create Accounts Payable
var liabilitiesParent []byte
if liabilitiesAccount, exists := accountMap["Liabilities"]; exists {
liabilitiesParent = liabilitiesAccount.ID
} else {
return fmt.Errorf("Liabilities account not found in accountMap")
}
accountsPayable := models.Account{
ID: id.New(),
OrgID: defaultOrg.ID,
Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Name: "Accounts Payable",
Parent: liabilitiesParent,
Currency: "USD",
Precision: 2,
DebitBalance: false,
}
if err := DB.Create(&accountsPayable).Error; err != nil {
return fmt.Errorf("failed to create Accounts Payable: %w", err)
}
// Now create sub-accounts under Current Assets
subAccounts := []models.Account{
{
ID: id.New(),
OrgID: defaultOrg.ID,
Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Name: "Cash",
Parent: currentAssets.ID,
Currency: "USD",
Precision: 2,
DebitBalance: true,
},
{
ID: id.New(),
OrgID: defaultOrg.ID,
Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)),
Name: "Accounts Receivable",
Parent: currentAssets.ID,
Currency: "USD",
Precision: 2,
DebitBalance: true,
},
}
for _, acc := range subAccounts {
if err := DB.Create(&acc).Error; err != nil {
return fmt.Errorf("failed to create sub-account %s: %w", acc.Name, err)
}
}
return nil
}