refactor: restructure in entirety
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -40,3 +40,6 @@ node_modules/
|
||||
|
||||
# Docker image tarball
|
||||
image.tar
|
||||
|
||||
# Binaries
|
||||
bin/
|
||||
|
||||
@@ -12,7 +12,11 @@ RUN go mod download
|
||||
COPY . .
|
||||
|
||||
# Compile SCSS to CSS
|
||||
RUN sass internal/web/assets/scss/main.scss internal/web/assets/css/output.css --style=compressed
|
||||
# Install Sass
|
||||
RUN npm install -g sass
|
||||
|
||||
# Compile SCSS
|
||||
RUN sass web/assets/scss/main.scss web/assets/css/output.css --style=compressed
|
||||
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o main cmd/api/main.go
|
||||
|
||||
|
||||
6
Makefile
6
Makefile
@@ -6,9 +6,9 @@
|
||||
|
||||
# Variables
|
||||
APP_NAME := billit
|
||||
MAIN_PATH := ./cmd/api
|
||||
SCSS_DIR := internal/web/assets/scss
|
||||
CSS_DIR := internal/web/assets/css
|
||||
MAIN_PATH := ./cmd/server
|
||||
SCSS_DIR := web/assets/scss
|
||||
CSS_DIR := web/assets/css
|
||||
|
||||
# Default target
|
||||
all: scss build
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"billit/internal/server"
|
||||
"billit/web"
|
||||
)
|
||||
|
||||
func gracefulShutdown(apiServer *http.Server, done chan bool) {
|
||||
@@ -39,7 +40,7 @@ func gracefulShutdown(apiServer *http.Server, done chan bool) {
|
||||
|
||||
func main() {
|
||||
|
||||
server := server.NewServer()
|
||||
server := server.NewServer(web.Files)
|
||||
|
||||
// Create a done channel to signal when the shutdown is complete
|
||||
done := make(chan bool, 1)
|
||||
@@ -47,6 +48,7 @@ func main() {
|
||||
// Run graceful shutdown in a separate goroutine
|
||||
go gracefulShutdown(server, done)
|
||||
|
||||
log.Printf("Server is starting on %s", server.Addr)
|
||||
err := server.ListenAndServe()
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
panic(fmt.Sprintf("http server error: %s", err))
|
||||
3
go.mod
3
go.mod
@@ -7,10 +7,11 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/labstack/echo/v4 v4.13.4
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
)
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.32
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"billit/internal/database"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// Handlers holds dependencies for API handlers
|
||||
type Handlers struct {
|
||||
db database.Service
|
||||
}
|
||||
|
||||
// NewHandlers creates API handlers with db access
|
||||
func NewHandlers(db database.Service) *Handlers {
|
||||
return &Handlers{db: db}
|
||||
}
|
||||
|
||||
// HealthHandler returns the health status
|
||||
func (h *Handlers) HealthHandler(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, h.db.Health())
|
||||
}
|
||||
|
||||
// Note: Product and Invoice API endpoints are disabled.
|
||||
// All operations go through the authenticated web UI.
|
||||
// To re-enable API access, add API authentication and update these handlers
|
||||
// to accept userID from authenticated API requests.
|
||||
@@ -1,69 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"billit/internal/database"
|
||||
)
|
||||
|
||||
// DBUserStore adapts database.Service to auth.UserStore interface
|
||||
type DBUserStore struct {
|
||||
db database.Service
|
||||
}
|
||||
|
||||
// NewDBUserStore creates a new user store backed by the database
|
||||
func NewDBUserStore(db database.Service) *DBUserStore {
|
||||
return &DBUserStore{db: db}
|
||||
}
|
||||
|
||||
// CreateUser creates a new user
|
||||
func (s *DBUserStore) CreateUser(email, passwordHash string) (*User, error) {
|
||||
dbUser, err := s.db.CreateUser(email, passwordHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &User{
|
||||
ID: dbUser.ID,
|
||||
Email: dbUser.Email,
|
||||
Password: dbUser.Password,
|
||||
CompanyDetails: dbUser.CompanyDetails,
|
||||
BankDetails: dbUser.BankDetails,
|
||||
CreatedAt: dbUser.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUserByEmail retrieves a user by email
|
||||
func (s *DBUserStore) GetUserByEmail(email string) (*User, error) {
|
||||
dbUser, err := s.db.GetUserByEmail(email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dbUser == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &User{
|
||||
ID: dbUser.ID,
|
||||
Email: dbUser.Email,
|
||||
Password: dbUser.Password,
|
||||
CompanyDetails: dbUser.CompanyDetails,
|
||||
BankDetails: dbUser.BankDetails,
|
||||
CreatedAt: dbUser.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by ID
|
||||
func (s *DBUserStore) GetUserByID(id string) (*User, error) {
|
||||
dbUser, err := s.db.GetUserByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dbUser == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &User{
|
||||
ID: dbUser.ID,
|
||||
Email: dbUser.Email,
|
||||
Password: dbUser.Password,
|
||||
CompanyDetails: dbUser.CompanyDetails,
|
||||
BankDetails: dbUser.BankDetails,
|
||||
CreatedAt: dbUser.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
63
internal/database/buyer.go
Normal file
63
internal/database/buyer.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"billit/internal/models"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreateBuyerDetails creates a new buyer details entry
|
||||
func (s *service) CreateBuyerDetails(userID string, name string, details string) (*models.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) (*models.BuyerDetails, error) {
|
||||
var b models.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) ([]models.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 []models.BuyerDetails
|
||||
for rows.Next() {
|
||||
var b models.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
|
||||
}
|
||||
@@ -1,569 +0,0 @@
|
||||
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()
|
||||
}
|
||||
99
internal/database/invoice.go
Normal file
99
internal/database/invoice.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"billit/internal/models"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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) (*models.Invoice, error) {
|
||||
var inv models.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) ([]models.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 []models.Invoice
|
||||
for rows.Next() {
|
||||
var inv models.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
|
||||
}
|
||||
|
||||
// GetRecentInvoices returns the most recently generated invoices for a user
|
||||
func (s *service) GetRecentInvoices(userID string, limit int) ([]models.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 []models.Invoice
|
||||
for rows.Next() {
|
||||
var inv models.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
|
||||
}
|
||||
82
internal/database/product.go
Normal file
82
internal/database/product.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"billit/internal/models"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// CreateProduct inserts a new product for a user
|
||||
func (s *service) CreateProduct(p models.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 models.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) ([]models.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 []models.Product
|
||||
for rows.Next() {
|
||||
var p models.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) (*models.Product, error) {
|
||||
var p models.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
|
||||
}
|
||||
|
||||
// GetRecentProducts returns the most recently added products for a user
|
||||
func (s *service) GetRecentProducts(userID string, limit int) ([]models.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 []models.Product
|
||||
for rows.Next() {
|
||||
var p models.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
|
||||
}
|
||||
141
internal/database/service.go
Normal file
141
internal/database/service.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"billit/internal/models"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// Service represents the database service interface
|
||||
type Service interface {
|
||||
// Health returns database health status
|
||||
Health() map[string]string
|
||||
|
||||
// Close closes the database connection
|
||||
Close() error
|
||||
|
||||
// Buyer methods
|
||||
CreateBuyerDetails(userID string, name string, details string) (*models.BuyerDetails, error)
|
||||
UpdateBuyerDetails(id string, userID string, name string, details string) error
|
||||
GetBuyerDetails(id string, userID string) (*models.BuyerDetails, error)
|
||||
GetAllBuyerDetails(userID string) ([]models.BuyerDetails, error)
|
||||
DeleteBuyerDetails(id string, userID string) error
|
||||
|
||||
// User methods
|
||||
CreateUser(email, passwordHash string) (*models.User, error)
|
||||
GetUserByEmail(email string) (*models.User, error)
|
||||
GetUserByID(id string) (*models.User, error)
|
||||
UpdateUserPassword(id string, passwordHash string) error
|
||||
UpdateUserDetails(id string, companyDetails string, bankDetails string, invoicePrefix string) error
|
||||
|
||||
// Invoice methods
|
||||
GetNextInvoiceNumber(userID string) (string, error)
|
||||
CreateInvoice(id string, humanReadableID string, data interface{}, userID string) error
|
||||
GetInvoice(id string, userID string) (*models.Invoice, error)
|
||||
GetAllInvoices(userID string) ([]models.Invoice, error)
|
||||
GetRecentInvoices(userID string, limit int) ([]models.Invoice, error)
|
||||
|
||||
// Product methods
|
||||
CreateProduct(p models.Product, userID string) error
|
||||
UpdateProduct(p models.Product, userID string) error
|
||||
GetAllProducts(userID string) ([]models.Product, error)
|
||||
GetProductBySKU(sku string, userID string) (*models.Product, error)
|
||||
DeleteProduct(sku string, userID string) error
|
||||
GetRecentProducts(userID string, limit int) ([]models.Product, error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
var dbInstance *service
|
||||
|
||||
// New creates a new database service
|
||||
func New() Service {
|
||||
// Reuse connection if already established
|
||||
if dbInstance != nil {
|
||||
return dbInstance
|
||||
}
|
||||
|
||||
dbPath := os.Getenv("DB_PATH")
|
||||
if dbPath == "" {
|
||||
dbPath = "./db/dev.db"
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Check connection
|
||||
if err := db.Ping(); err != nil {
|
||||
log.Fatal("Could not ping database:", err)
|
||||
}
|
||||
|
||||
dbInstance = &service{
|
||||
db: db,
|
||||
}
|
||||
|
||||
return dbInstance
|
||||
}
|
||||
|
||||
// Health checks the health of the database connection
|
||||
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.Printf("db down: %v", err)
|
||||
return stats
|
||||
}
|
||||
|
||||
// Database is up, add more statistics
|
||||
stats["status"] = "up"
|
||||
stats["message"] = "It's healthy"
|
||||
|
||||
// Get database stats
|
||||
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)
|
||||
|
||||
if dbStats.OpenConnections > 40 {
|
||||
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 connection usage."
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (s *service) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
58
internal/database/user.go
Normal file
58
internal/database/user.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"billit/internal/models"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreateUser creates a new user with hashed password
|
||||
func (s *service) CreateUser(email, passwordHash string) (*models.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) (*models.User, error) {
|
||||
var u models.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) (*models.User, error) {
|
||||
var u models.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
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package gst
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCalculateLineItem(t *testing.T) {
|
||||
c := NewCalculator()
|
||||
|
||||
product := Product{
|
||||
SKU: "TEST01",
|
||||
BasePrice: 100,
|
||||
WholesalePrice: 90,
|
||||
GSTRate: Rate18,
|
||||
}
|
||||
|
||||
// Case 1: Retail, Intra-state
|
||||
item := c.CalculateLineItem(product, 2, CustomerRetail, false)
|
||||
if item.UnitPrice != 100 {
|
||||
t.Errorf("Expected UnitPrice 100, got %f", item.UnitPrice)
|
||||
}
|
||||
if item.TaxableVal != 200 {
|
||||
t.Errorf("Expected TaxableVal 200, got %f", item.TaxableVal)
|
||||
}
|
||||
if item.CGSTAmount != 18 { // 9% of 200
|
||||
t.Errorf("Expected CGST 18, got %f", item.CGSTAmount)
|
||||
}
|
||||
if item.IGSTAmount != 0 {
|
||||
t.Errorf("Expected IGST 0, got %f", item.IGSTAmount)
|
||||
}
|
||||
if item.TotalAmount != 236 { // 200 + 18 + 18
|
||||
t.Errorf("Expected Total 236, got %f", item.TotalAmount)
|
||||
}
|
||||
|
||||
// Case 2: Wholesale, Inter-state
|
||||
item = c.CalculateLineItem(product, 10, CustomerWholesale, true)
|
||||
if item.UnitPrice != 90 {
|
||||
t.Errorf("Expected UnitPrice 90, got %f", item.UnitPrice)
|
||||
}
|
||||
if item.TaxableVal != 900 {
|
||||
t.Errorf("Expected TaxableVal 900, got %f", item.TaxableVal)
|
||||
}
|
||||
if item.CGSTAmount != 0 {
|
||||
t.Errorf("Expected CGST 0, got %f", item.CGSTAmount)
|
||||
}
|
||||
if item.IGSTAmount != 162 { // 18% of 900
|
||||
t.Errorf("Expected IGST 162, got %f", item.IGSTAmount)
|
||||
}
|
||||
if item.TotalAmount != 1062 { // 900 + 162
|
||||
t.Errorf("Expected Total 1062, got %f", item.TotalAmount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateInvoice(t *testing.T) {
|
||||
c := NewCalculator()
|
||||
product := Product{SKU: "TEST01", BasePrice: 100, GSTRate: Rate18}
|
||||
|
||||
item1 := c.CalculateLineItem(product, 1, CustomerRetail, false)
|
||||
item2 := c.CalculateLineItem(product, 1, CustomerRetail, false)
|
||||
|
||||
// Test with convenience fee (intra-state)
|
||||
invoice := c.CalculateInvoice([]LineItem{item1, item2}, 50, false)
|
||||
|
||||
// Convenience fee is taxed at 18%
|
||||
// SubTotal should include convenience fee: 100 + 100 + 50 = 250
|
||||
expectedSubTotal := 250.0
|
||||
if invoice.SubTotal != expectedSubTotal {
|
||||
t.Errorf("Expected SubTotal %f, got %f", expectedSubTotal, invoice.SubTotal)
|
||||
}
|
||||
|
||||
// Convenience fee tax: 50 * 0.18 = 9
|
||||
expectedFeeTax := 9.0
|
||||
if invoice.ConvenienceFeeTax != expectedFeeTax {
|
||||
t.Errorf("Expected ConvenienceFeeTax %f, got %f", expectedFeeTax, invoice.ConvenienceFeeTax)
|
||||
}
|
||||
|
||||
// Total CGST: 9 + 9 + 4.5 = 22.5 (from items + half of fee tax)
|
||||
expectedCGST := 22.5
|
||||
if invoice.TotalCGST != expectedCGST {
|
||||
t.Errorf("Expected TotalCGST %f, got %f", expectedCGST, invoice.TotalCGST)
|
||||
}
|
||||
|
||||
// GrandTotal: SubTotal + CGST + SGST = 250 + 22.5 + 22.5 = 295
|
||||
expectedTotal := 295.0
|
||||
if invoice.GrandTotal != expectedTotal {
|
||||
t.Errorf("Expected GrandTotal %f, got %f", expectedTotal, invoice.GrandTotal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateInvoiceInterState(t *testing.T) {
|
||||
c := NewCalculator()
|
||||
product := Product{SKU: "TEST01", BasePrice: 100, GSTRate: Rate18}
|
||||
|
||||
item := c.CalculateLineItem(product, 1, CustomerRetail, true)
|
||||
|
||||
// Test with convenience fee (inter-state)
|
||||
invoice := c.CalculateInvoice([]LineItem{item}, 50, true)
|
||||
|
||||
// SubTotal: 100 + 50 = 150
|
||||
expectedSubTotal := 150.0
|
||||
if invoice.SubTotal != expectedSubTotal {
|
||||
t.Errorf("Expected SubTotal %f, got %f", expectedSubTotal, invoice.SubTotal)
|
||||
}
|
||||
|
||||
// Convenience fee tax: 50 * 0.18 = 9
|
||||
expectedFeeTax := 9.0
|
||||
if invoice.ConvenienceFeeTax != expectedFeeTax {
|
||||
t.Errorf("Expected ConvenienceFeeTax %f, got %f", expectedFeeTax, invoice.ConvenienceFeeTax)
|
||||
}
|
||||
|
||||
// Total IGST: 18 + 9 = 27 (from item + fee tax)
|
||||
expectedIGST := 27.0
|
||||
if invoice.TotalIGST != expectedIGST {
|
||||
t.Errorf("Expected TotalIGST %f, got %f", expectedIGST, invoice.TotalIGST)
|
||||
}
|
||||
|
||||
// GrandTotal: SubTotal + IGST = 150 + 27 = 177
|
||||
expectedTotal := 177.0
|
||||
if invoice.GrandTotal != expectedTotal {
|
||||
t.Errorf("Expected GrandTotal %f, got %f", expectedTotal, invoice.GrandTotal)
|
||||
}
|
||||
}
|
||||
108
internal/handler/account.go
Normal file
108
internal/handler/account.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"billit/internal/database"
|
||||
"billit/internal/logic"
|
||||
"billit/internal/view"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// AccountHandlers holds references for account operations
|
||||
type AccountHandlers struct {
|
||||
db database.Service
|
||||
auth *logic.AuthService
|
||||
}
|
||||
|
||||
// NewAccountHandlers creates handlers with db and auth access
|
||||
func NewAccountHandlers(db database.Service, authService *logic.AuthService) *AccountHandlers {
|
||||
return &AccountHandlers{db: db, auth: authService}
|
||||
}
|
||||
|
||||
// AccountPageHandler renders the /account page
|
||||
func (h *AccountHandlers) AccountPageHandler(c echo.Context) error {
|
||||
userID := getUserID(c)
|
||||
if userID == "" {
|
||||
return c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
user, err := h.db.GetUserByID(userID)
|
||||
if err != nil || user == nil {
|
||||
return view.RenderServerError(c, "Failed to load account details.")
|
||||
}
|
||||
|
||||
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", ""))
|
||||
}
|
||||
|
||||
// UpdateDetailsHandler handles POST /account/details
|
||||
func (h *AccountHandlers) UpdateDetailsHandler(c echo.Context) error {
|
||||
userID := getUserID(c)
|
||||
if userID == "" {
|
||||
return c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
user, err := h.db.GetUserByID(userID)
|
||||
if err != nil || user == nil {
|
||||
return view.RenderServerError(c, "Failed to load account details.")
|
||||
}
|
||||
|
||||
companyDetails := c.FormValue("company_details")
|
||||
bankDetails := c.FormValue("bank_details")
|
||||
invoicePrefix := c.FormValue("invoice_prefix")
|
||||
if invoicePrefix == "" {
|
||||
invoicePrefix = "INV" // Default prefix
|
||||
}
|
||||
|
||||
err = h.db.UpdateUserDetails(userID, companyDetails, bankDetails, invoicePrefix)
|
||||
if err != nil {
|
||||
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update details"))
|
||||
}
|
||||
|
||||
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, companyDetails, bankDetails, invoicePrefix, "Details updated successfully", ""))
|
||||
}
|
||||
|
||||
// ChangePasswordHandler handles POST /account/password
|
||||
func (h *AccountHandlers) ChangePasswordHandler(c echo.Context) error {
|
||||
userID := getUserID(c)
|
||||
if userID == "" {
|
||||
return c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
user, err := h.db.GetUserByID(userID)
|
||||
if err != nil || user == nil {
|
||||
return view.RenderServerError(c, "Failed to load account details.")
|
||||
}
|
||||
|
||||
currentPassword := c.FormValue("current_password")
|
||||
newPassword := c.FormValue("new_password")
|
||||
confirmPassword := c.FormValue("confirm_password")
|
||||
|
||||
// Validate current password
|
||||
if !logic.CheckPassword(currentPassword, user.Password) {
|
||||
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Current password is incorrect"))
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
if len(newPassword) < 8 {
|
||||
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "New password must be at least 8 characters"))
|
||||
}
|
||||
|
||||
if newPassword != confirmPassword {
|
||||
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "New passwords do not match"))
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
hash, err := logic.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update password"))
|
||||
}
|
||||
|
||||
// Update password in database
|
||||
err = h.db.UpdateUserPassword(userID, hash)
|
||||
if err != nil {
|
||||
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update password"))
|
||||
}
|
||||
|
||||
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "Password changed successfully", ""))
|
||||
}
|
||||
@@ -1,24 +1,62 @@
|
||||
package web
|
||||
package handler
|
||||
|
||||
import (
|
||||
"billit/internal/auth"
|
||||
"billit/internal/logic"
|
||||
"billit/internal/view"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// AuthHandlers holds auth service reference
|
||||
type AuthHandlers struct {
|
||||
auth *auth.Service
|
||||
auth *logic.AuthService
|
||||
}
|
||||
|
||||
// NewAuthHandlers creates handlers with auth service
|
||||
func NewAuthHandlers(authService *auth.Service) *AuthHandlers {
|
||||
func NewAuthHandlers(authService *logic.AuthService) *AuthHandlers {
|
||||
return &AuthHandlers{auth: authService}
|
||||
}
|
||||
|
||||
// createAuthCookie creates an HTTP-only secure cookie for the token
|
||||
func createAuthCookie(token string) *http.Cookie {
|
||||
domain := os.Getenv("COOKIE_DOMAIN")
|
||||
secure := os.Getenv("COOKIE_SECURE") == "true"
|
||||
|
||||
return &http.Cookie{
|
||||
Name: "auth_token",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Domain: domain,
|
||||
MaxAge: int(24 * time.Hour.Seconds()), // Match token duration
|
||||
HttpOnly: true, // Prevents JavaScript access
|
||||
Secure: secure, // Only send over HTTPS in production
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
}
|
||||
}
|
||||
|
||||
// clearAuthCookie returns a cookie that clears the auth token
|
||||
func clearAuthCookie() *http.Cookie {
|
||||
domain := os.Getenv("COOKIE_DOMAIN")
|
||||
secure := os.Getenv("COOKIE_SECURE") == "true"
|
||||
|
||||
return &http.Cookie{
|
||||
Name: "auth_token",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Domain: domain,
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// LoginPageHandler renders the login page (home page)
|
||||
func (h *AuthHandlers) LoginPageHandler(c echo.Context) error {
|
||||
// Check if already logged in
|
||||
@@ -32,7 +70,7 @@ func (h *AuthHandlers) LoginPageHandler(c echo.Context) error {
|
||||
}
|
||||
// Capture redirect URL from query param
|
||||
redirectURL := c.QueryParam("redirect")
|
||||
return Render(c, LoginPage("", "", redirectURL))
|
||||
return view.Render(c, view.LoginPage("", "", redirectURL))
|
||||
}
|
||||
|
||||
// LoginHandler handles login form submission
|
||||
@@ -42,16 +80,16 @@ func (h *AuthHandlers) LoginHandler(c echo.Context) error {
|
||||
redirectURL := c.FormValue("redirect")
|
||||
|
||||
if email == "" || password == "" {
|
||||
return Render(c, LoginPage("Email and password are required", email, redirectURL))
|
||||
return view.Render(c, view.LoginPage("Email and password are required", email, redirectURL))
|
||||
}
|
||||
|
||||
token, err := h.auth.Login(email, password)
|
||||
if err != nil {
|
||||
return Render(c, LoginPage("Invalid email or password", email, redirectURL))
|
||||
return view.Render(c, view.LoginPage("Invalid email or password", email, redirectURL))
|
||||
}
|
||||
|
||||
// Set HTTP-only cookie
|
||||
cookie := h.auth.CreateAuthCookie(token)
|
||||
cookie := createAuthCookie(token)
|
||||
c.SetCookie(cookie)
|
||||
|
||||
// Redirect to original URL or home page
|
||||
@@ -63,7 +101,7 @@ func (h *AuthHandlers) LoginHandler(c echo.Context) error {
|
||||
|
||||
// RegisterPageHandler renders the registration page
|
||||
func (h *AuthHandlers) RegisterPageHandler(c echo.Context) error {
|
||||
return Render(c, RegisterPage("", ""))
|
||||
return view.Render(c, view.RegisterPage("", ""))
|
||||
}
|
||||
|
||||
// RegisterHandler handles registration form submission
|
||||
@@ -73,23 +111,23 @@ func (h *AuthHandlers) RegisterHandler(c echo.Context) error {
|
||||
confirmPassword := c.FormValue("confirm_password")
|
||||
|
||||
if email == "" || password == "" {
|
||||
return Render(c, RegisterPage("Email and password are required", email))
|
||||
return view.Render(c, view.RegisterPage("Email and password are required", email))
|
||||
}
|
||||
|
||||
if password != confirmPassword {
|
||||
return Render(c, RegisterPage("Passwords do not match", email))
|
||||
return view.Render(c, view.RegisterPage("Passwords do not match", email))
|
||||
}
|
||||
|
||||
if len(password) < 8 {
|
||||
return Render(c, RegisterPage("Password must be at least 8 characters", email))
|
||||
return view.Render(c, view.RegisterPage("Password must be at least 8 characters", email))
|
||||
}
|
||||
|
||||
_, err := h.auth.Register(email, password)
|
||||
if err != nil {
|
||||
if err == auth.ErrUserExists {
|
||||
return Render(c, RegisterPage("An account with this email already exists", email))
|
||||
if err == logic.ErrUserExists {
|
||||
return view.Render(c, view.RegisterPage("An account with this email already exists", email))
|
||||
}
|
||||
return Render(c, RegisterPage(err.Error(), email))
|
||||
return view.Render(c, view.RegisterPage(err.Error(), email))
|
||||
}
|
||||
|
||||
// Auto-login after registration
|
||||
@@ -98,7 +136,7 @@ func (h *AuthHandlers) RegisterHandler(c echo.Context) error {
|
||||
return c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
cookie := h.auth.CreateAuthCookie(token)
|
||||
cookie := createAuthCookie(token)
|
||||
c.SetCookie(cookie)
|
||||
|
||||
return c.Redirect(http.StatusFound, "/home")
|
||||
@@ -106,7 +144,7 @@ func (h *AuthHandlers) RegisterHandler(c echo.Context) error {
|
||||
|
||||
// LogoutHandler clears the auth cookie and redirects to login
|
||||
func (h *AuthHandlers) LogoutHandler(c echo.Context) error {
|
||||
cookie := h.auth.ClearAuthCookie()
|
||||
cookie := clearAuthCookie()
|
||||
c.SetCookie(cookie)
|
||||
return c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
@@ -124,9 +162,9 @@ func (h *AuthHandlers) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
claims, err := h.auth.ValidateToken(cookie.Value)
|
||||
if err != nil {
|
||||
// Invalid/expired token - show session expired dialog
|
||||
c.SetCookie(h.auth.ClearAuthCookie())
|
||||
c.SetCookie(clearAuthCookie())
|
||||
redirectPath := url.QueryEscape(c.Request().URL.RequestURI())
|
||||
return Render(c, SessionExpiredPage(redirectPath))
|
||||
return view.Render(c, view.SessionExpiredPage(redirectPath))
|
||||
}
|
||||
|
||||
// Store user info in context
|
||||
@@ -1,8 +1,10 @@
|
||||
package web
|
||||
package handler
|
||||
|
||||
import "billit/internal/models"
|
||||
import "billit/internal/view"
|
||||
import (
|
||||
"billit/internal/database"
|
||||
"billit/internal/gst"
|
||||
"billit/internal/logic"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -98,13 +100,13 @@ func (h *BillingHandlers) BillingPageHandler(c echo.Context) error {
|
||||
userID := getUserID(c)
|
||||
products, err := h.db.GetAllProducts(userID)
|
||||
if err != nil {
|
||||
products = []database.Product{}
|
||||
products = []models.Product{}
|
||||
}
|
||||
buyers, err := h.db.GetAllBuyerDetails(userID)
|
||||
if err != nil {
|
||||
buyers = []database.BuyerDetails{}
|
||||
buyers = []models.BuyerDetails{}
|
||||
}
|
||||
return Render(c, BillingPage(products, buyers))
|
||||
return view.Render(c, view.BillingPage(products, buyers))
|
||||
}
|
||||
|
||||
// CalculateBillHandler calculates the bill (HTMX endpoint)
|
||||
@@ -114,14 +116,14 @@ func (h *BillingHandlers) CalculateBillHandler(c echo.Context) error {
|
||||
regionType := c.FormValue("region_type")
|
||||
includeConvenienceFee := c.FormValue("include_convenience_fee") == "yes"
|
||||
|
||||
cType := gst.CustomerRetail
|
||||
cType := logic.CustomerRetail
|
||||
if customerType == "wholesale" {
|
||||
cType = gst.CustomerWholesale
|
||||
cType = logic.CustomerWholesale
|
||||
}
|
||||
isInterState := regionType == "inter"
|
||||
|
||||
calculator := gst.NewCalculator()
|
||||
var items []gst.LineItem
|
||||
calculator := logic.NewCalculator()
|
||||
var items []logic.LineItem
|
||||
var totalFee float64
|
||||
|
||||
// Support up to 50 product slots for dynamic adding
|
||||
@@ -144,14 +146,14 @@ func (h *BillingHandlers) CalculateBillHandler(c echo.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert to gst.Product
|
||||
product := gst.Product{
|
||||
// Convert to logic.Product
|
||||
product := logic.Product{
|
||||
SKU: dbProduct.SKU,
|
||||
Name: dbProduct.Name,
|
||||
HSNCode: dbProduct.HSNCode,
|
||||
BasePrice: dbProduct.BasePrice,
|
||||
WholesalePrice: dbProduct.WholesalePrice,
|
||||
GSTRate: gst.Rate(dbProduct.GSTRate),
|
||||
GSTRate: logic.Rate(dbProduct.GSTRate),
|
||||
SmallOrderQty: dbProduct.SmallOrderQty,
|
||||
SmallOrderFee: dbProduct.SmallOrderFee,
|
||||
Unit: dbProduct.Unit,
|
||||
@@ -169,7 +171,7 @@ func (h *BillingHandlers) CalculateBillHandler(c echo.Context) error {
|
||||
invoice := calculator.CalculateInvoice(items, totalFee, isInterState)
|
||||
invoice.CustomerType = cType
|
||||
|
||||
return Render(c, InvoiceSummary(invoice))
|
||||
return view.Render(c, view.InvoiceSummary(invoice))
|
||||
}
|
||||
|
||||
// GenerateBillHandler generates final invoice with UUID and persists to DB
|
||||
@@ -184,14 +186,14 @@ func (h *BillingHandlers) GenerateBillHandler(c echo.Context) error {
|
||||
buyerID := c.FormValue("buyer_id")
|
||||
includeConvenienceFee := c.FormValue("include_convenience_fee") == "yes"
|
||||
|
||||
cType := gst.CustomerRetail
|
||||
cType := logic.CustomerRetail
|
||||
if customerType == "wholesale" {
|
||||
cType = gst.CustomerWholesale
|
||||
cType = logic.CustomerWholesale
|
||||
}
|
||||
isInterState := regionType == "inter"
|
||||
|
||||
calculator := gst.NewCalculator()
|
||||
var items []gst.LineItem
|
||||
calculator := logic.NewCalculator()
|
||||
var items []logic.LineItem
|
||||
var totalFee float64
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
@@ -212,13 +214,13 @@ func (h *BillingHandlers) GenerateBillHandler(c echo.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
product := gst.Product{
|
||||
product := logic.Product{
|
||||
SKU: dbProduct.SKU,
|
||||
Name: dbProduct.Name,
|
||||
HSNCode: dbProduct.HSNCode,
|
||||
BasePrice: dbProduct.BasePrice,
|
||||
WholesalePrice: dbProduct.WholesalePrice,
|
||||
GSTRate: gst.Rate(dbProduct.GSTRate),
|
||||
GSTRate: logic.Rate(dbProduct.GSTRate),
|
||||
SmallOrderQty: dbProduct.SmallOrderQty,
|
||||
SmallOrderFee: dbProduct.SmallOrderFee,
|
||||
Unit: dbProduct.Unit,
|
||||
@@ -286,11 +288,11 @@ func (h *BillingHandlers) ShowInvoiceHandler(c echo.Context) error {
|
||||
|
||||
inv, err := h.db.GetInvoice(invoiceID, userID)
|
||||
if err != nil || inv == nil {
|
||||
return RenderNotFound(c, "Invoice not found or you don't have access to it.")
|
||||
return view.RenderNotFound(c, "Invoice not found or you don't have access to it.")
|
||||
}
|
||||
|
||||
// Parse the JSON data back into Invoice struct
|
||||
var invoice gst.Invoice
|
||||
var invoice logic.Invoice
|
||||
if err := json.Unmarshal([]byte(inv.Data), &invoice); err != nil {
|
||||
return c.String(http.StatusInternalServerError, "failed to parse invoice data")
|
||||
}
|
||||
@@ -345,7 +347,7 @@ func (h *BillingHandlers) ShowInvoiceHandler(c echo.Context) error {
|
||||
fmt.Fprintf(w, `<div class="invoice-details-block" style="margin-bottom:1rem;"><strong>From:</strong><br>%s</div>`, invoice.CompanyDetails)
|
||||
}
|
||||
|
||||
if err := PrintableInvoice(invoice).Render(c.Request().Context(), w); err != nil {
|
||||
if err := view.PrintableInvoice(invoice).Render(c.Request().Context(), w); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package web
|
||||
package handler
|
||||
|
||||
import "billit/internal/view"
|
||||
import (
|
||||
"billit/internal/database"
|
||||
"net/http"
|
||||
@@ -22,14 +23,14 @@ func (h *BuyerHandlers) BuyerListHandler(c echo.Context) error {
|
||||
userID := getUserID(c)
|
||||
buyers, err := h.db.GetAllBuyerDetails(userID)
|
||||
if err != nil {
|
||||
return RenderServerError(c, "Failed to load buyers. Please try again.")
|
||||
return view.RenderServerError(c, "Failed to load buyers. Please try again.")
|
||||
}
|
||||
return Render(c, BuyerListPage(buyers))
|
||||
return view.Render(c, view.BuyerListPage(buyers))
|
||||
}
|
||||
|
||||
// BuyerCreatePageHandler renders the /buyer/create form page
|
||||
func (h *BuyerHandlers) BuyerCreatePageHandler(c echo.Context) error {
|
||||
return Render(c, BuyerCreatePage())
|
||||
return view.Render(c, view.BuyerCreatePage())
|
||||
}
|
||||
|
||||
// BuyerEditPageHandler renders the /buyer/edit/:id form page
|
||||
@@ -39,9 +40,9 @@ func (h *BuyerHandlers) BuyerEditPageHandler(c echo.Context) error {
|
||||
|
||||
buyer, err := h.db.GetBuyerDetails(id, userID)
|
||||
if err != nil || buyer == nil {
|
||||
return RenderNotFound(c, "Buyer not found or you don't have access to it.")
|
||||
return view.RenderNotFound(c, "Buyer not found or you don't have access to it.")
|
||||
}
|
||||
return Render(c, BuyerEditPage(*buyer))
|
||||
return view.Render(c, view.BuyerEditPage(*buyer))
|
||||
}
|
||||
|
||||
// BuyerCreateHandler handles POST /buyer/create
|
||||
23
internal/handler/health.go
Normal file
23
internal/handler/health.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"billit/internal/database"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HealthHandlers holds dependencies for health checks
|
||||
type HealthHandlers struct {
|
||||
db database.Service
|
||||
}
|
||||
|
||||
// NewHealthHandlers creates health handlers with db access
|
||||
func NewHealthHandlers(db database.Service) *HealthHandlers {
|
||||
return &HealthHandlers{db: db}
|
||||
}
|
||||
|
||||
// HealthHandler returns the health status
|
||||
func (h *HealthHandlers) HealthHandler(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, h.db.Health())
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package web
|
||||
package handler
|
||||
|
||||
import "billit/internal/models"
|
||||
import "billit/internal/view"
|
||||
import (
|
||||
"billit/internal/database"
|
||||
|
||||
@@ -24,14 +26,14 @@ func (h *HomeHandlers) HomePageHandler(c echo.Context) error {
|
||||
// Get recent products (last 5)
|
||||
recentProducts, err := h.db.GetRecentProducts(userID, 5)
|
||||
if err != nil {
|
||||
recentProducts = []database.Product{}
|
||||
recentProducts = []models.Product{}
|
||||
}
|
||||
|
||||
// Get recent invoices (last 5)
|
||||
recentInvoices, err := h.db.GetRecentInvoices(userID, 5)
|
||||
if err != nil {
|
||||
recentInvoices = []database.Invoice{}
|
||||
recentInvoices = []models.Invoice{}
|
||||
}
|
||||
|
||||
return Render(c, HomePage(userEmail, recentProducts, recentInvoices))
|
||||
return view.Render(c, view.HomePage(userEmail, recentProducts, recentInvoices))
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package web
|
||||
package handler
|
||||
|
||||
import "billit/internal/view"
|
||||
import (
|
||||
"billit/internal/database"
|
||||
"net/http"
|
||||
@@ -26,7 +27,7 @@ func (h *InvoicesHandlers) InvoicesListHandler(c echo.Context) error {
|
||||
|
||||
invoices, err := h.db.GetAllInvoices(userID)
|
||||
if err != nil {
|
||||
return RenderServerError(c, "Failed to load invoices. Please try again.")
|
||||
return view.RenderServerError(c, "Failed to load invoices. Please try again.")
|
||||
}
|
||||
return Render(c, InvoicesPage(invoices))
|
||||
return view.Render(c, view.InvoicesPage(invoices))
|
||||
}
|
||||
53
internal/handler/modal.go
Normal file
53
internal/handler/modal.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"billit/internal/view"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// ModalHandlers holds dependencies
|
||||
type ModalHandlers struct{}
|
||||
|
||||
// NewModalHandlers creates new handlers
|
||||
func NewModalHandlers() *ModalHandlers {
|
||||
return &ModalHandlers{}
|
||||
}
|
||||
|
||||
// ConfirmHandler renders the confirmation modal
|
||||
func (h *ModalHandlers) ConfirmHandler(c echo.Context) error {
|
||||
title := c.QueryParam("title")
|
||||
if title == "" {
|
||||
title = "Confirm Action"
|
||||
}
|
||||
|
||||
message := c.QueryParam("message")
|
||||
if message == "" {
|
||||
message = "Are you sure you want to proceed?"
|
||||
}
|
||||
|
||||
confirmText := c.QueryParam("confirm_text")
|
||||
if confirmText == "" {
|
||||
confirmText = "Confirm"
|
||||
}
|
||||
|
||||
url := c.QueryParam("url")
|
||||
method := c.QueryParam("method")
|
||||
if method == "" {
|
||||
method = "delete" // Default to delete
|
||||
}
|
||||
|
||||
target := c.QueryParam("target")
|
||||
|
||||
props := view.ModalProps{
|
||||
Title: title,
|
||||
Message: message,
|
||||
ConfirmText: confirmText,
|
||||
ConfirmURL: url,
|
||||
Method: strings.ToLower(method),
|
||||
Target: target,
|
||||
}
|
||||
|
||||
return view.Render(c, view.Modal(props))
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package web
|
||||
package handler
|
||||
|
||||
import "billit/internal/models"
|
||||
import "billit/internal/view"
|
||||
import (
|
||||
"billit/internal/database"
|
||||
"net/http"
|
||||
@@ -31,14 +33,14 @@ func (h *ProductHandlers) ProductListHandler(c echo.Context) error {
|
||||
userID := getUserID(c)
|
||||
products, err := h.db.GetAllProducts(userID)
|
||||
if err != nil {
|
||||
return RenderServerError(c, "Failed to load products. Please try again.")
|
||||
return view.RenderServerError(c, "Failed to load products. Please try again.")
|
||||
}
|
||||
return Render(c, ProductListPage(products))
|
||||
return view.Render(c, view.ProductListPage(products))
|
||||
}
|
||||
|
||||
// ProductCreatePageHandler renders the /product/create form page
|
||||
func (h *ProductHandlers) ProductCreatePageHandler(c echo.Context) error {
|
||||
return Render(c, ProductCreatePage())
|
||||
return view.Render(c, view.ProductCreatePage())
|
||||
}
|
||||
|
||||
// ProductEditPageHandler renders the /product/edit/:sku form page
|
||||
@@ -48,9 +50,9 @@ func (h *ProductHandlers) ProductEditPageHandler(c echo.Context) error {
|
||||
|
||||
product, err := h.db.GetProductBySKU(sku, userID)
|
||||
if err != nil || product == nil {
|
||||
return RenderNotFound(c, "Product not found or you don't have access to it.")
|
||||
return view.RenderNotFound(c, "Product not found or you don't have access to it.")
|
||||
}
|
||||
return Render(c, ProductEditPage(*product))
|
||||
return view.Render(c, view.ProductEditPage(*product))
|
||||
}
|
||||
|
||||
// ProductCreateHandler handles POST /product/create
|
||||
@@ -115,7 +117,7 @@ func (h *ProductHandlers) ProductCreateHandler(c echo.Context) error {
|
||||
unit = "pcs"
|
||||
}
|
||||
|
||||
product := database.Product{
|
||||
product := models.Product{
|
||||
SKU: sku,
|
||||
Name: name,
|
||||
HSNCode: hsn,
|
||||
@@ -194,7 +196,7 @@ func (h *ProductHandlers) ProductUpdateHandler(c echo.Context) error {
|
||||
unit = "pcs"
|
||||
}
|
||||
|
||||
product := database.Product{
|
||||
product := models.Product{
|
||||
SKU: sku,
|
||||
Name: name,
|
||||
HSNCode: hsn,
|
||||
@@ -1,10 +1,10 @@
|
||||
package auth
|
||||
package logic
|
||||
|
||||
import (
|
||||
"billit/internal/models"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -19,24 +19,12 @@ var (
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
)
|
||||
|
||||
// Config holds auth configuration
|
||||
type Config struct {
|
||||
// AuthConfig holds auth configuration
|
||||
type AuthConfig struct {
|
||||
JWTSecret []byte
|
||||
CookieDomain string
|
||||
CookieSecure bool
|
||||
TokenDuration time.Duration
|
||||
}
|
||||
|
||||
// User represents an authenticated user
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"-"` // Never expose password hash
|
||||
CompanyDetails string `json:"company_details"`
|
||||
BankDetails string `json:"bank_details"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Claims represents JWT claims
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
@@ -44,21 +32,21 @@ type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Service handles authentication
|
||||
type Service struct {
|
||||
config Config
|
||||
// AuthService handles authentication logic
|
||||
type AuthService struct {
|
||||
config AuthConfig
|
||||
users UserStore
|
||||
}
|
||||
|
||||
// UserStore interface for user persistence
|
||||
// UserStore interface for user persistence (subset of database.Service)
|
||||
type UserStore interface {
|
||||
CreateUser(email, passwordHash string) (*User, error)
|
||||
GetUserByEmail(email string) (*User, error)
|
||||
GetUserByID(id string) (*User, error)
|
||||
CreateUser(email, passwordHash string) (*models.User, error)
|
||||
GetUserByEmail(email string) (*models.User, error)
|
||||
GetUserByID(id string) (*models.User, error)
|
||||
}
|
||||
|
||||
// NewService creates a new auth service
|
||||
func NewService(users UserStore) *Service {
|
||||
// NewAuthService creates a new auth service
|
||||
func NewAuthService(users UserStore) *AuthService {
|
||||
secret := os.Getenv("JWT_SECRET")
|
||||
if secret == "" {
|
||||
// Generate a random secret if not provided (not recommended for production)
|
||||
@@ -67,14 +55,9 @@ func NewService(users UserStore) *Service {
|
||||
secret = hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
domain := os.Getenv("COOKIE_DOMAIN")
|
||||
secure := os.Getenv("COOKIE_SECURE") == "true"
|
||||
|
||||
return &Service{
|
||||
config: Config{
|
||||
return &AuthService{
|
||||
config: AuthConfig{
|
||||
JWTSecret: []byte(secret),
|
||||
CookieDomain: domain,
|
||||
CookieSecure: secure,
|
||||
TokenDuration: 24 * time.Hour,
|
||||
},
|
||||
users: users,
|
||||
@@ -98,7 +81,7 @@ func CheckPassword(password, hash string) bool {
|
||||
}
|
||||
|
||||
// Register creates a new user account
|
||||
func (s *Service) Register(email, password string) (*User, error) {
|
||||
func (s *AuthService) Register(email, password string) (*models.User, error) {
|
||||
// Check if user exists
|
||||
existing, _ := s.users.GetUserByEmail(email)
|
||||
if existing != nil {
|
||||
@@ -120,7 +103,7 @@ func (s *Service) Register(email, password string) (*User, error) {
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns a JWT token
|
||||
func (s *Service) Login(email, password string) (string, error) {
|
||||
func (s *AuthService) Login(email, password string) (string, error) {
|
||||
user, err := s.users.GetUserByEmail(email)
|
||||
if err != nil || user == nil {
|
||||
return "", ErrInvalidCredentials
|
||||
@@ -134,7 +117,7 @@ func (s *Service) Login(email, password string) (string, error) {
|
||||
}
|
||||
|
||||
// generateToken creates a new JWT token for a user
|
||||
func (s *Service) generateToken(user *User) (string, error) {
|
||||
func (s *AuthService) generateToken(user *models.User) (string, error) {
|
||||
now := time.Now()
|
||||
claims := &Claims{
|
||||
UserID: user.ID,
|
||||
@@ -153,7 +136,7 @@ func (s *Service) generateToken(user *User) (string, error) {
|
||||
}
|
||||
|
||||
// ValidateToken validates a JWT token and returns the claims
|
||||
func (s *Service) ValidateToken(tokenString string) (*Claims, error) {
|
||||
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, ErrInvalidToken
|
||||
@@ -172,36 +155,8 @@ func (s *Service) ValidateToken(tokenString string) (*Claims, error) {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// CreateAuthCookie creates an HTTP-only secure cookie for the token
|
||||
func (s *Service) CreateAuthCookie(token string) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: "auth_token",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Domain: s.config.CookieDomain,
|
||||
MaxAge: int(s.config.TokenDuration.Seconds()),
|
||||
HttpOnly: true, // Prevents JavaScript access
|
||||
Secure: s.config.CookieSecure, // Only send over HTTPS in production
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
}
|
||||
}
|
||||
|
||||
// ClearAuthCookie returns a cookie that clears the auth token
|
||||
func (s *Service) ClearAuthCookie() *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: "auth_token",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Domain: s.config.CookieDomain,
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: s.config.CookieSecure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserFromToken retrieves the user from a valid token
|
||||
func (s *Service) GetUserFromToken(tokenString string) (*User, error) {
|
||||
func (s *AuthService) GetUserFromToken(tokenString string) (*models.User, error) {
|
||||
claims, err := s.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1,4 +1,4 @@
|
||||
package gst
|
||||
package logic
|
||||
|
||||
import (
|
||||
"math"
|
||||
46
internal/models/models.go
Normal file
46
internal/models/models.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package models
|
||||
|
||||
// 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"`
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"billit/internal/handler"
|
||||
"billit/internal/logic"
|
||||
"billit/internal/view"
|
||||
"net/http"
|
||||
|
||||
"billit/internal/api"
|
||||
"billit/internal/auth"
|
||||
"billit/internal/web"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
@@ -25,23 +24,24 @@ func (s *Server) RegisterRoutes() http.Handler {
|
||||
}))
|
||||
|
||||
// Static files
|
||||
fileServer := http.FileServer(http.FS(web.Files))
|
||||
if s.assetsFS != nil {
|
||||
fileServer := http.FileServer(http.FS(s.assetsFS))
|
||||
e.GET("/assets/*", echo.WrapHandler(fileServer))
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Auth Setup
|
||||
// ========================================
|
||||
userStore := auth.NewDBUserStore(s.db)
|
||||
authService := auth.NewService(userStore)
|
||||
authHandlers := web.NewAuthHandlers(authService)
|
||||
authService := logic.NewAuthService(s.db)
|
||||
authHandlers := handler.NewAuthHandlers(authService)
|
||||
|
||||
// ========================================
|
||||
// API Routes (JSON responses) - Health only, products/invoice via web UI
|
||||
// API Routes (JSON responses) - Health only
|
||||
// ========================================
|
||||
apiHandlers := api.NewHandlers(s.db)
|
||||
healthHandlers := handler.NewHealthHandlers(s.db)
|
||||
apiGroup := e.Group("/api")
|
||||
{
|
||||
apiGroup.GET("/health", apiHandlers.HealthHandler)
|
||||
apiGroup.GET("/health", healthHandlers.HealthHandler)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
@@ -60,17 +60,17 @@ func (s *Server) RegisterRoutes() http.Handler {
|
||||
protected.Use(authHandlers.AuthMiddleware)
|
||||
|
||||
// Home
|
||||
homeHandlers := web.NewHomeHandlers(s.db)
|
||||
homeHandlers := handler.NewHomeHandlers(s.db)
|
||||
protected.GET("/home", homeHandlers.HomePageHandler)
|
||||
|
||||
// Account routes
|
||||
accountHandlers := web.NewAccountHandlers(s.db, authService)
|
||||
accountHandlers := handler.NewAccountHandlers(s.db, authService)
|
||||
protected.GET("/account", accountHandlers.AccountPageHandler)
|
||||
protected.POST("/account/details", accountHandlers.UpdateDetailsHandler)
|
||||
protected.POST("/account/password", accountHandlers.ChangePasswordHandler)
|
||||
|
||||
// Buyer routes
|
||||
buyerHandlers := web.NewBuyerHandlers(s.db)
|
||||
buyerHandlers := handler.NewBuyerHandlers(s.db)
|
||||
protected.GET("/buyer", buyerHandlers.BuyerListHandler)
|
||||
protected.GET("/buyer/create", buyerHandlers.BuyerCreatePageHandler)
|
||||
protected.POST("/buyer/create", buyerHandlers.BuyerCreateHandler)
|
||||
@@ -79,11 +79,15 @@ func (s *Server) RegisterRoutes() http.Handler {
|
||||
protected.DELETE("/buyer/:id", buyerHandlers.BuyerDeleteHandler)
|
||||
|
||||
// Invoices list
|
||||
invoicesHandlers := web.NewInvoicesHandlers(s.db)
|
||||
invoicesHandlers := handler.NewInvoicesHandlers(s.db)
|
||||
protected.GET("/invoice", invoicesHandlers.InvoicesListHandler)
|
||||
|
||||
// Modal routes
|
||||
modalHandlers := handler.NewModalHandlers()
|
||||
protected.GET("/modal/confirm", modalHandlers.ConfirmHandler)
|
||||
|
||||
// Product routes (web UI)
|
||||
productHandlers := web.NewProductHandlers(s.db)
|
||||
productHandlers := handler.NewProductHandlers(s.db)
|
||||
protected.GET("/product", productHandlers.ProductListHandler)
|
||||
protected.GET("/product/create", productHandlers.ProductCreatePageHandler)
|
||||
protected.POST("/product/create", productHandlers.ProductCreateHandler)
|
||||
@@ -92,7 +96,7 @@ func (s *Server) RegisterRoutes() http.Handler {
|
||||
protected.DELETE("/product/:sku", productHandlers.ProductDeleteHandler)
|
||||
|
||||
// Billing routes (web UI)
|
||||
billingHandlers := web.NewBillingHandlers(s.db)
|
||||
billingHandlers := handler.NewBillingHandlers(s.db)
|
||||
protected.GET("/billing", billingHandlers.BillingPageHandler)
|
||||
protected.POST("/billing/calculate", billingHandlers.CalculateBillHandler)
|
||||
protected.POST("/billing/generate", billingHandlers.GenerateBillHandler)
|
||||
@@ -102,17 +106,17 @@ func (s *Server) RegisterRoutes() http.Handler {
|
||||
protected.GET("/invoice/:id", billingHandlers.ShowInvoiceHandler)
|
||||
|
||||
// Legacy health check (kept for backward compatibility)
|
||||
e.GET("/health", apiHandlers.HealthHandler)
|
||||
e.GET("/health", healthHandlers.HealthHandler)
|
||||
|
||||
// Custom 404 handler for Echo HTTP errors
|
||||
e.HTTPErrorHandler = func(err error, c echo.Context) {
|
||||
if he, ok := err.(*echo.HTTPError); ok {
|
||||
switch he.Code {
|
||||
case http.StatusNotFound:
|
||||
_ = web.RenderNotFound(c, "")
|
||||
_ = view.RenderNotFound(c, "")
|
||||
return
|
||||
case http.StatusInternalServerError:
|
||||
_ = web.RenderServerError(c, "")
|
||||
_ = view.RenderServerError(c, "")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -122,7 +126,7 @@ func (s *Server) RegisterRoutes() http.Handler {
|
||||
|
||||
// Catch-all for undefined routes (must be last)
|
||||
e.RouteNotFound("/*", func(c echo.Context) error {
|
||||
return web.RenderNotFound(c, "")
|
||||
return view.RenderNotFound(c, "")
|
||||
})
|
||||
|
||||
return e
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"billit/internal/database"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func TestHomeRoute(t *testing.T) {
|
||||
// Create a minimal server with db for testing
|
||||
db := database.New()
|
||||
s := &Server{db: db}
|
||||
handler := s.RegisterRoutes()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Errorf("home route wrong status code = %v, want %v", resp.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterSetup(t *testing.T) {
|
||||
// Test that Echo router can be set up without panic
|
||||
e := echo.New()
|
||||
if e == nil {
|
||||
t.Error("failed to create echo instance")
|
||||
}
|
||||
}
|
||||
@@ -10,20 +10,22 @@ import (
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
|
||||
"billit/internal/database"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
port int
|
||||
|
||||
db database.Service
|
||||
assetsFS fs.FS
|
||||
}
|
||||
|
||||
func NewServer() *http.Server {
|
||||
func NewServer(assetsFS fs.FS) *http.Server {
|
||||
port, _ := strconv.Atoi(os.Getenv("PORT"))
|
||||
NewServer := &Server{
|
||||
port: port,
|
||||
|
||||
db: database.New(),
|
||||
assetsFS: assetsFS,
|
||||
}
|
||||
|
||||
// Declare Server config
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package web
|
||||
package view
|
||||
|
||||
import (
|
||||
"github.com/a-h/templ"
|
||||
@@ -1,107 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"billit/internal/auth"
|
||||
"billit/internal/database"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// AccountHandlers holds references for account operations
|
||||
type AccountHandlers struct {
|
||||
db database.Service
|
||||
auth *auth.Service
|
||||
}
|
||||
|
||||
// NewAccountHandlers creates handlers with db and auth access
|
||||
func NewAccountHandlers(db database.Service, authService *auth.Service) *AccountHandlers {
|
||||
return &AccountHandlers{db: db, auth: authService}
|
||||
}
|
||||
|
||||
// AccountPageHandler renders the /account page
|
||||
func (h *AccountHandlers) AccountPageHandler(c echo.Context) error {
|
||||
userID := getUserID(c)
|
||||
if userID == "" {
|
||||
return c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
user, err := h.db.GetUserByID(userID)
|
||||
if err != nil || user == nil {
|
||||
return RenderServerError(c, "Failed to load account details.")
|
||||
}
|
||||
|
||||
return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", ""))
|
||||
}
|
||||
|
||||
// UpdateDetailsHandler handles POST /account/details
|
||||
func (h *AccountHandlers) UpdateDetailsHandler(c echo.Context) error {
|
||||
userID := getUserID(c)
|
||||
if userID == "" {
|
||||
return c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
user, err := h.db.GetUserByID(userID)
|
||||
if err != nil || user == nil {
|
||||
return RenderServerError(c, "Failed to load account details.")
|
||||
}
|
||||
|
||||
companyDetails := c.FormValue("company_details")
|
||||
bankDetails := c.FormValue("bank_details")
|
||||
invoicePrefix := c.FormValue("invoice_prefix")
|
||||
if invoicePrefix == "" {
|
||||
invoicePrefix = "INV" // Default prefix
|
||||
}
|
||||
|
||||
err = h.db.UpdateUserDetails(userID, companyDetails, bankDetails, invoicePrefix)
|
||||
if err != nil {
|
||||
return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update details"))
|
||||
}
|
||||
|
||||
return Render(c, AccountPage(user.Email, user.CreatedAt, companyDetails, bankDetails, invoicePrefix, "Details updated successfully", ""))
|
||||
}
|
||||
|
||||
// ChangePasswordHandler handles POST /account/password
|
||||
func (h *AccountHandlers) ChangePasswordHandler(c echo.Context) error {
|
||||
userID := getUserID(c)
|
||||
if userID == "" {
|
||||
return c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
user, err := h.db.GetUserByID(userID)
|
||||
if err != nil || user == nil {
|
||||
return RenderServerError(c, "Failed to load account details.")
|
||||
}
|
||||
|
||||
currentPassword := c.FormValue("current_password")
|
||||
newPassword := c.FormValue("new_password")
|
||||
confirmPassword := c.FormValue("confirm_password")
|
||||
|
||||
// Validate current password
|
||||
if !auth.CheckPassword(currentPassword, user.Password) {
|
||||
return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Current password is incorrect"))
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
if len(newPassword) < 8 {
|
||||
return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "New password must be at least 8 characters"))
|
||||
}
|
||||
|
||||
if newPassword != confirmPassword {
|
||||
return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "New passwords do not match"))
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
hash, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update password"))
|
||||
}
|
||||
|
||||
// Update password in database
|
||||
err = h.db.UpdateUserPassword(userID, hash)
|
||||
if err != nil {
|
||||
return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update password"))
|
||||
}
|
||||
|
||||
return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "Password changed successfully", ""))
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
// Dialog component for Billit
|
||||
// Replaces browser confirm/alert dialogs with custom styled modals
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Dialog state
|
||||
let currentResolve = null;
|
||||
let currentElement = null;
|
||||
|
||||
// Create dialog HTML structure
|
||||
function createDialogElement() {
|
||||
const dialog = document.createElement('div');
|
||||
dialog.id = 'dialog';
|
||||
dialog.className = 'dialog-overlay';
|
||||
dialog.innerHTML = `
|
||||
<div class="dialog-box">
|
||||
<div class="dialog-header">
|
||||
<h3 class="dialog-title"></h3>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<p class="dialog-message"></p>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn btn-outline dialog-cancel">Cancel</button>
|
||||
<button type="button" class="btn btn-danger dialog-confirm">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
// Event listeners
|
||||
dialog.querySelector('.dialog-cancel').addEventListener('click', () => closeDialog(false));
|
||||
dialog.querySelector('.dialog-confirm').addEventListener('click', () => closeDialog(true));
|
||||
dialog.addEventListener('click', (e) => {
|
||||
if (e.target === dialog) closeDialog(false);
|
||||
});
|
||||
|
||||
// Escape key closes dialog
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && dialog.classList.contains('dialog-open')) {
|
||||
closeDialog(false);
|
||||
}
|
||||
});
|
||||
|
||||
return dialog;
|
||||
}
|
||||
|
||||
// Get or create dialog element
|
||||
function getDialog() {
|
||||
return document.getElementById('dialog') || createDialogElement();
|
||||
}
|
||||
|
||||
// Open dialog with options
|
||||
function openDialog(options) {
|
||||
const dialog = getDialog();
|
||||
const title = options.title || 'Confirm';
|
||||
const message = options.message || 'Are you sure?';
|
||||
const confirmText = options.confirmText || 'Confirm';
|
||||
const cancelText = options.cancelText || 'Cancel';
|
||||
const confirmClass = options.confirmClass || 'btn-danger';
|
||||
const html = options.html || null;
|
||||
const wide = options.wide || false;
|
||||
const allowClose = options.allowClose !== false;
|
||||
|
||||
dialog.querySelector('.dialog-title').textContent = title;
|
||||
|
||||
// Support HTML content
|
||||
if (html) {
|
||||
dialog.querySelector('.dialog-body').innerHTML = html;
|
||||
} else {
|
||||
dialog.querySelector('.dialog-body').innerHTML = '<p class="dialog-message">' + escapeHtml(message) + '</p>';
|
||||
}
|
||||
|
||||
dialog.querySelector('.dialog-confirm').textContent = confirmText;
|
||||
dialog.querySelector('.dialog-confirm').className = 'btn ' + confirmClass + ' dialog-confirm';
|
||||
dialog.querySelector('.dialog-cancel').textContent = cancelText;
|
||||
|
||||
// Show/hide cancel button for alert-style dialogs
|
||||
dialog.querySelector('.dialog-cancel').style.display = options.showCancel !== false ? '' : 'none';
|
||||
|
||||
// Wide mode for larger content
|
||||
dialog.querySelector('.dialog-box').style.maxWidth = wide ? '600px' : '400px';
|
||||
|
||||
// Store allowClose setting
|
||||
dialog.dataset.allowClose = allowClose;
|
||||
|
||||
dialog.classList.add('dialog-open');
|
||||
dialog.querySelector('.dialog-confirm').focus();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
currentResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
// Close dialog
|
||||
function closeDialog(result) {
|
||||
const dialog = getDialog();
|
||||
|
||||
// Check if closing is allowed (for disclaimer)
|
||||
if (!result && dialog.dataset.allowClose === 'false') {
|
||||
return;
|
||||
}
|
||||
|
||||
dialog.classList.remove('dialog-open');
|
||||
|
||||
if (currentResolve) {
|
||||
currentResolve(result);
|
||||
currentResolve = null;
|
||||
}
|
||||
|
||||
// If there's a pending HTMX request, trigger it
|
||||
if (result && currentElement) {
|
||||
htmx.trigger(currentElement, 'confirmed');
|
||||
}
|
||||
currentElement = null;
|
||||
}
|
||||
|
||||
// Escape HTML for safe rendering
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Public API
|
||||
window.Dialog = {
|
||||
confirm: function(options) {
|
||||
if (typeof options === 'string') {
|
||||
options = { message: options };
|
||||
}
|
||||
return openDialog({ ...options, showCancel: true });
|
||||
},
|
||||
|
||||
alert: function(options) {
|
||||
if (typeof options === 'string') {
|
||||
options = { message: options };
|
||||
}
|
||||
return openDialog({
|
||||
...options,
|
||||
showCancel: false,
|
||||
confirmText: options.confirmText || 'OK',
|
||||
confirmClass: options.confirmClass || 'btn-primary'
|
||||
});
|
||||
},
|
||||
|
||||
// Custom dialog with HTML content
|
||||
custom: function(options) {
|
||||
return openDialog(options);
|
||||
}
|
||||
};
|
||||
|
||||
// HTMX integration: intercept hx-confirm and use custom dialog
|
||||
// Elements can customize the dialog with data attributes:
|
||||
// data-dialog-title="Custom Title"
|
||||
// data-dialog-confirm="Button Text"
|
||||
// data-dialog-class="btn-danger" (or btn-primary, etc.)
|
||||
// If no data-dialog-* attributes are present, uses browser default confirm
|
||||
document.addEventListener('htmx:confirm', function(e) {
|
||||
const element = e.detail.elt;
|
||||
|
||||
// Check if element wants custom dialog (has any data-dialog-* attribute)
|
||||
const hasCustomDialog = element.dataset.dialogTitle ||
|
||||
element.dataset.dialogConfirm ||
|
||||
element.dataset.dialogClass;
|
||||
|
||||
if (!hasCustomDialog) {
|
||||
return; // Let default browser confirm handle it
|
||||
}
|
||||
|
||||
// Prevent default browser confirm
|
||||
e.preventDefault();
|
||||
|
||||
const message = e.detail.question;
|
||||
const title = element.dataset.dialogTitle || 'Confirm';
|
||||
const confirmText = element.dataset.dialogConfirm || 'Confirm';
|
||||
const confirmClass = element.dataset.dialogClass || 'btn-primary';
|
||||
|
||||
// Store element for later
|
||||
currentElement = element;
|
||||
|
||||
Dialog.confirm({
|
||||
title: title,
|
||||
message: message,
|
||||
confirmText: confirmText,
|
||||
confirmClass: confirmClass
|
||||
}).then(function(confirmed) {
|
||||
if (confirmed) {
|
||||
// Issue the request
|
||||
e.detail.issueRequest(true);
|
||||
}
|
||||
currentElement = null;
|
||||
});
|
||||
});
|
||||
|
||||
// Disclaimer dialog - show on first visit
|
||||
function showDisclaimer() {
|
||||
const DISCLAIMER_KEY = 'billit_disclaimer_accepted';
|
||||
|
||||
// Check if already accepted
|
||||
if (localStorage.getItem(DISCLAIMER_KEY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const disclaimerHTML = `
|
||||
<div class="disclaimer-content">
|
||||
<p style="font-weight: bold; margin-bottom: 15px;">
|
||||
Please read these terms carefully before using this software. By proceeding, you agree to the conditions below:
|
||||
</p>
|
||||
<ul style="padding-left: 20px; line-height: 1.8; margin: 0;">
|
||||
<li>
|
||||
<strong>1. FREE OF CHARGE & CPA EXEMPTION:</strong> This software is provided strictly <strong>"Free of Charge"</strong> and without any monetary consideration. It therefore does not constitute a "Service" under the Indian Consumer Protection Act, 2019.
|
||||
</li>
|
||||
<li style="margin-top: 10px;">
|
||||
<strong>2. "AS IS" & NO WARRANTY:</strong> The software is provided <strong>"AS IS"</strong>. The developer provides <strong>NO WARRANTY</strong>, express or implied, regarding its performance, accuracy, security, or suitability for any purpose.
|
||||
</li>
|
||||
<li style="margin-top: 10px;">
|
||||
<strong>3. USER ASSUMPTION OF RISK:</strong> The developer is not liable for any financial losses, data corruption, calculation errors, or legal issues resulting from the use or misuse of this application. Users assume all associated risks and agree to indemnify and hold harmless the developer.
|
||||
</li>
|
||||
</ul>
|
||||
<p style="font-size: 0.9em; font-style: italic; color: #666; margin-top: 15px; margin-bottom: 0;">
|
||||
<small>Consult a qualified legal or financial advisor before relying on any data generated by this tool.</small>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Dialog.custom({
|
||||
title: '⚠️ GENERAL USE & NO LIABILITY DISCLAIMER',
|
||||
html: disclaimerHTML,
|
||||
confirmText: 'I Understand & Accept',
|
||||
confirmClass: 'btn-primary',
|
||||
showCancel: false,
|
||||
wide: true,
|
||||
allowClose: false
|
||||
}).then(function(accepted) {
|
||||
if (accepted) {
|
||||
localStorage.setItem(DISCLAIMER_KEY, Date.now().toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show disclaimer when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', showDisclaimer);
|
||||
} else {
|
||||
showDisclaimer();
|
||||
}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user