Files
billit/internal/database/database.go
Arkaprabha Chakraborty 28733e22d3 quite a lot of things
2025-12-06 03:05:44 +05:30

570 lines
20 KiB
Go

package database
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"time"
_ "github.com/joho/godotenv/autoload"
_ "github.com/mattn/go-sqlite3"
)
// Product represents a product in the database
type Product struct {
SKU string `json:"sku"`
Name string `json:"name"`
HSNCode string `json:"hsn_code"`
BasePrice float64 `json:"base_price"`
WholesalePrice float64 `json:"wholesale_price"`
GSTRate float64 `json:"gst_rate"`
SmallOrderQty int `json:"small_order_qty"`
SmallOrderFee float64 `json:"small_order_fee"` // Convenience fee for orders below SmallOrderQty
Unit string `json:"unit"` // Unit of measurement (e.g., "pcs", "kg", "box")
UserID string `json:"user_id"`
CreatedAt string `json:"created_at"`
}
// Invoice represents a stored invoice
type Invoice struct {
ID string `json:"id"` // UUID
HumanReadableID string `json:"human_readable_id"` // Formatted ID like INV/12-2025/001
Data string `json:"data"` // JSON blob of invoice details
UserID string `json:"user_id"`
CreatedAt string `json:"created_at"`
}
// User represents an authenticated user
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Password string `json:"-"`
CompanyDetails string `json:"company_details"` // Multiline company details for invoice header
BankDetails string `json:"bank_details"` // Multiline bank details for invoice footer
InvoicePrefix string `json:"invoice_prefix"` // Prefix for invoice IDs (e.g., INV, BILL)
InvoiceCounter int `json:"invoice_counter"` // Auto-incrementing counter for invoice serial numbers
CreatedAt string `json:"created_at"`
}
// BuyerDetails represents a buyer/customer for invoices
type BuyerDetails struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"` // Display name for selection
Details string `json:"details"` // Multiline buyer details
CreatedAt string `json:"created_at"`
}
// Service represents a service that interacts with a database.
// Service represents a service that interacts with a database.
type Service interface {
// Health returns a map of health status information.
Health() map[string]string
// Close terminates the database connection.
Close() error
// Product operations (user-scoped)
CreateProduct(p Product, userID string) error
UpdateProduct(p Product, userID string) error
GetAllProducts(userID string) ([]Product, error)
GetProductBySKU(sku string, userID string) (*Product, error)
DeleteProduct(sku string, userID string) error
// Invoice operations (user-scoped)
CreateInvoice(id string, humanReadableID string, data interface{}, userID string) error
GetInvoice(id string, userID string) (*Invoice, error)
GetAllInvoices(userID string) ([]Invoice, error)
GetRecentProducts(userID string, limit int) ([]Product, error)
GetRecentInvoices(userID string, limit int) ([]Invoice, error)
GetNextInvoiceNumber(userID string) (string, error) // Returns formatted invoice ID and increments counter
// User operations
CreateUser(email, passwordHash string) (*User, error)
GetUserByEmail(email string) (*User, error)
GetUserByID(id string) (*User, error)
UpdateUserPassword(id string, passwordHash string) error
UpdateUserDetails(id string, companyDetails string, bankDetails string, invoicePrefix string) error
// Buyer details operations
CreateBuyerDetails(userID string, name string, details string) (*BuyerDetails, error)
UpdateBuyerDetails(id string, userID string, name string, details string) error
GetBuyerDetails(id string, userID string) (*BuyerDetails, error)
GetAllBuyerDetails(userID string) ([]BuyerDetails, error)
DeleteBuyerDetails(id string, userID string) error
}
type service struct {
db *sql.DB
}
var (
dburl = os.Getenv("DB_PATH")
dbInstance *service
)
func New() Service {
// Reuse Connection
if dbInstance != nil {
return dbInstance
}
// Ensure the directory for the database file exists
if dburl != "" && dburl != ":memory:" {
dir := filepath.Dir(dburl)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
log.Fatalf("Failed to create database directory: %v", err)
}
}
}
db, err := sql.Open("sqlite3", dburl)
if err != nil {
log.Fatal(err)
}
dbInstance = &service{
db: db,
}
// Initialize tables
dbInstance.initTables()
return dbInstance
}
func (s *service) initTables() {
// Products table with user ownership
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS products (
sku TEXT NOT NULL,
name TEXT NOT NULL,
hsn_code TEXT,
base_price REAL NOT NULL,
wholesale_price REAL,
gst_rate REAL NOT NULL DEFAULT 0.18,
small_order_qty INTEGER DEFAULT 1,
small_order_fee REAL DEFAULT 0,
unit TEXT DEFAULT 'pcs',
user_id TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (sku, user_id)
)
`)
if err != nil {
log.Printf("Error creating products table: %v", err)
}
// Add user_id column if not exists (migration for existing DBs)
s.db.Exec(`ALTER TABLE products ADD COLUMN user_id TEXT DEFAULT ''`)
// Add unit column if not exists (migration for existing DBs)
s.db.Exec(`ALTER TABLE products ADD COLUMN unit TEXT DEFAULT 'pcs'`)
// Invoices table with user ownership
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS invoices (
id TEXT PRIMARY KEY,
human_readable_id TEXT DEFAULT '',
data TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
log.Printf("Error creating invoices table: %v", err)
}
// Add columns if not exists (migration for existing DBs)
s.db.Exec(`ALTER TABLE invoices ADD COLUMN user_id TEXT DEFAULT ''`)
s.db.Exec(`ALTER TABLE invoices ADD COLUMN human_readable_id TEXT DEFAULT ''`)
// Create index on user_id for fast lookups
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_products_user ON products(user_id)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_invoices_user ON invoices(user_id)`)
// Users table
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
company_details TEXT DEFAULT '',
bank_details TEXT DEFAULT '',
invoice_prefix TEXT DEFAULT 'INV',
invoice_counter INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
log.Printf("Error creating users table: %v", err)
}
// Add columns if not exists (migration for existing DBs)
s.db.Exec(`ALTER TABLE users ADD COLUMN company_details TEXT DEFAULT ''`)
s.db.Exec(`ALTER TABLE users ADD COLUMN bank_details TEXT DEFAULT ''`)
s.db.Exec(`ALTER TABLE users ADD COLUMN invoice_prefix TEXT DEFAULT 'INV'`)
s.db.Exec(`ALTER TABLE users ADD COLUMN invoice_counter INTEGER DEFAULT 0`)
s.db.Exec(`ALTER TABLE users ADD COLUMN invoice_prefix TEXT DEFAULT 'INV'`)
s.db.Exec(`ALTER TABLE users ADD COLUMN invoice_counter INTEGER DEFAULT 0`)
// Create index on email for fast lookups
_, err = s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`)
if err != nil {
log.Printf("Error creating users email index: %v", err)
}
// Buyer details table
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS buyer_details (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
details TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
log.Printf("Error creating buyer_details table: %v", err)
}
// Create index on user_id for fast lookups
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_buyer_details_user ON buyer_details(user_id)`)
}
// CreateProduct inserts a new product for a user
func (s *service) CreateProduct(p Product, userID string) error {
_, err := s.db.Exec(`
INSERT INTO products (sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, p.SKU, p.Name, p.HSNCode, p.BasePrice, p.WholesalePrice, p.GSTRate, p.SmallOrderQty, p.SmallOrderFee, p.Unit, userID)
return err
}
// UpdateProduct updates an existing product for a user
func (s *service) UpdateProduct(p Product, userID string) error {
_, err := s.db.Exec(`
UPDATE products SET name=?, hsn_code=?, base_price=?, wholesale_price=?, gst_rate=?, small_order_qty=?, small_order_fee=?, unit=?
WHERE sku=? AND user_id=?
`, p.Name, p.HSNCode, p.BasePrice, p.WholesalePrice, p.GSTRate, p.SmallOrderQty, p.SmallOrderFee, p.Unit, p.SKU, userID)
return err
}
// GetAllProducts returns all products for a user
func (s *service) GetAllProducts(userID string) ([]Product, error) {
rows, err := s.db.Query(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE user_id=? ORDER BY name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var products []Product
for rows.Next() {
var p Product
if err := rows.Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt); err != nil {
return nil, err
}
products = append(products, p)
}
return products, nil
}
// GetProductBySKU returns a single product by SKU for a user
func (s *service) GetProductBySKU(sku string, userID string) (*Product, error) {
var p Product
err := s.db.QueryRow(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE sku=? AND user_id=?`, sku, userID).
Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &p, nil
}
// DeleteProduct removes a product by SKU for a user
func (s *service) DeleteProduct(sku string, userID string) error {
_, err := s.db.Exec(`DELETE FROM products WHERE sku=? AND user_id=?`, sku, userID)
return err
}
// GetNextInvoiceNumber generates the next invoice ID in format PREFIX/MMM-YYYY/XXX and increments the counter
func (s *service) GetNextInvoiceNumber(userID string) (string, error) {
var prefix string
var counter int
// Get current prefix and counter
err := s.db.QueryRow(`SELECT COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0) FROM users WHERE id = ?`, userID).
Scan(&prefix, &counter)
if err != nil {
return "", err
}
// Increment counter
counter++
// Update counter in database
_, err = s.db.Exec(`UPDATE users SET invoice_counter = ? WHERE id = ?`, counter, userID)
if err != nil {
return "", err
}
// Generate formatted invoice ID: PREFIX/MMM-YYYY/XXX
now := time.Now()
humanReadableID := fmt.Sprintf("%s/%s-%d/%03d", prefix, now.Month().String()[:3], now.Year(), counter)
return humanReadableID, nil
}
// CreateInvoice stores an invoice with UUID and human-readable ID for a user
func (s *service) CreateInvoice(id string, humanReadableID string, data interface{}, userID string) error {
jsonData, err := json.Marshal(data)
if err != nil {
return err
}
_, err = s.db.Exec(`INSERT INTO invoices (id, human_readable_id, data, user_id) VALUES (?, ?, ?, ?)`, id, humanReadableID, string(jsonData), userID)
return err
}
// GetInvoice retrieves an invoice by ID for a user
func (s *service) GetInvoice(id string, userID string) (*Invoice, error) {
var inv Invoice
err := s.db.QueryRow(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE id=? AND user_id=?`, id, userID).
Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &inv, nil
}
// GetAllInvoices retrieves all invoices for a user
func (s *service) GetAllInvoices(userID string) ([]Invoice, error) {
rows, err := s.db.Query(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE user_id=? ORDER BY created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var invoices []Invoice
for rows.Next() {
var inv Invoice
if err := rows.Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt); err != nil {
return nil, err
}
invoices = append(invoices, inv)
}
return invoices, nil
}
// GetRecentProducts returns the most recently added products for a user
func (s *service) GetRecentProducts(userID string, limit int) ([]Product, error) {
rows, err := s.db.Query(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE user_id=? ORDER BY created_at DESC LIMIT ?`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var products []Product
for rows.Next() {
var p Product
if err := rows.Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt); err != nil {
return nil, err
}
products = append(products, p)
}
return products, nil
}
// GetRecentInvoices returns the most recently generated invoices for a user
func (s *service) GetRecentInvoices(userID string, limit int) ([]Invoice, error) {
rows, err := s.db.Query(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE user_id=? ORDER BY created_at DESC LIMIT ?`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var invoices []Invoice
for rows.Next() {
var inv Invoice
if err := rows.Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt); err != nil {
return nil, err
}
invoices = append(invoices, inv)
}
return invoices, nil
}
// CreateUser creates a new user with hashed password
func (s *service) CreateUser(email, passwordHash string) (*User, error) {
id := fmt.Sprintf("%d", time.Now().UnixNano())
_, err := s.db.Exec(`INSERT INTO users (id, email, password) VALUES (?, ?, ?)`, id, email, passwordHash)
if err != nil {
return nil, err
}
return s.GetUserByID(id)
}
// GetUserByEmail retrieves a user by email
func (s *service) GetUserByEmail(email string) (*User, error) {
var u User
err := s.db.QueryRow(`SELECT id, email, password, COALESCE(company_details, ''), COALESCE(bank_details, ''), COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0), created_at FROM users WHERE email = ?`, email).
Scan(&u.ID, &u.Email, &u.Password, &u.CompanyDetails, &u.BankDetails, &u.InvoicePrefix, &u.InvoiceCounter, &u.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
// GetUserByID retrieves a user by ID
func (s *service) GetUserByID(id string) (*User, error) {
var u User
err := s.db.QueryRow(`SELECT id, email, password, COALESCE(company_details, ''), COALESCE(bank_details, ''), COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0), created_at FROM users WHERE id = ?`, id).
Scan(&u.ID, &u.Email, &u.Password, &u.CompanyDetails, &u.BankDetails, &u.InvoicePrefix, &u.InvoiceCounter, &u.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
// UpdateUserPassword updates a user's password hash
func (s *service) UpdateUserPassword(id string, passwordHash string) error {
_, err := s.db.Exec(`UPDATE users SET password = ? WHERE id = ?`, passwordHash, id)
return err
}
// UpdateUserDetails updates a user's company and bank details
func (s *service) UpdateUserDetails(id string, companyDetails string, bankDetails string, invoicePrefix string) error {
_, err := s.db.Exec(`UPDATE users SET company_details = ?, bank_details = ?, invoice_prefix = ? WHERE id = ?`, companyDetails, bankDetails, invoicePrefix, id)
return err
}
// CreateBuyerDetails creates a new buyer details entry
func (s *service) CreateBuyerDetails(userID string, name string, details string) (*BuyerDetails, error) {
id := fmt.Sprintf("%d", time.Now().UnixNano())
_, err := s.db.Exec(`INSERT INTO buyer_details (id, user_id, name, details) VALUES (?, ?, ?, ?)`, id, userID, name, details)
if err != nil {
return nil, err
}
return s.GetBuyerDetails(id, userID)
}
// UpdateBuyerDetails updates an existing buyer details entry
func (s *service) UpdateBuyerDetails(id string, userID string, name string, details string) error {
_, err := s.db.Exec(`UPDATE buyer_details SET name = ?, details = ? WHERE id = ? AND user_id = ?`, name, details, id, userID)
return err
}
// GetBuyerDetails retrieves a buyer details entry by ID
func (s *service) GetBuyerDetails(id string, userID string) (*BuyerDetails, error) {
var b BuyerDetails
err := s.db.QueryRow(`SELECT id, user_id, name, details, created_at FROM buyer_details WHERE id = ? AND user_id = ?`, id, userID).
Scan(&b.ID, &b.UserID, &b.Name, &b.Details, &b.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &b, nil
}
// GetAllBuyerDetails retrieves all buyer details for a user
func (s *service) GetAllBuyerDetails(userID string) ([]BuyerDetails, error) {
rows, err := s.db.Query(`SELECT id, user_id, name, details, created_at FROM buyer_details WHERE user_id = ? ORDER BY name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var buyers []BuyerDetails
for rows.Next() {
var b BuyerDetails
if err := rows.Scan(&b.ID, &b.UserID, &b.Name, &b.Details, &b.CreatedAt); err != nil {
return nil, err
}
buyers = append(buyers, b)
}
return buyers, nil
}
// DeleteBuyerDetails removes a buyer details entry
func (s *service) DeleteBuyerDetails(id string, userID string) error {
_, err := s.db.Exec(`DELETE FROM buyer_details WHERE id = ? AND user_id = ?`, id, userID)
return err
}
// Health checks the health of the database connection by pinging the database.
// It returns a map with keys indicating various health statistics.
func (s *service) Health() map[string]string {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
stats := make(map[string]string)
// Ping the database
err := s.db.PingContext(ctx)
if err != nil {
stats["status"] = "down"
stats["error"] = fmt.Sprintf("db down: %v", err)
log.Fatalf("db down: %v", err) // Log the error and terminate the program
return stats
}
// Database is up, add more statistics
stats["status"] = "up"
stats["message"] = "It's healthy"
// Get database stats (like open connections, in use, idle, etc.)
dbStats := s.db.Stats()
stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections)
stats["in_use"] = strconv.Itoa(dbStats.InUse)
stats["idle"] = strconv.Itoa(dbStats.Idle)
stats["wait_count"] = strconv.FormatInt(dbStats.WaitCount, 10)
stats["wait_duration"] = dbStats.WaitDuration.String()
stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleClosed, 10)
stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeClosed, 10)
// Evaluate stats to provide a health message
if dbStats.OpenConnections > 40 { // Assuming 50 is the max for this example
stats["message"] = "The database is experiencing heavy load."
}
if dbStats.WaitCount > 1000 {
stats["message"] = "The database has a high number of wait events, indicating potential bottlenecks."
}
if dbStats.MaxIdleClosed > int64(dbStats.OpenConnections)/2 {
stats["message"] = "Many idle connections are being closed, consider revising the connection pool settings."
}
if dbStats.MaxLifetimeClosed > int64(dbStats.OpenConnections)/2 {
stats["message"] = "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising the connection usage pattern."
}
return stats
}
// Close closes the database connection.
// It logs a message indicating the disconnection from the specific database.
// If the connection is successfully closed, it returns nil.
// If an error occurs while closing the connection, it returns the error.
func (s *service) Close() error {
log.Printf("Disconnected from database: %s", dburl)
return s.db.Close()
}