diff --git a/.gitignore b/.gitignore index 24efaf9..7a06208 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules frontend/dist build +bills.db diff --git a/app.go b/app.go index a29c6af..4b43295 100644 --- a/app.go +++ b/app.go @@ -2,6 +2,7 @@ package main import ( "context" + "time" ) // App struct @@ -19,3 +20,40 @@ func NewApp() *App { func (a *App) startup(ctx context.Context) { a.ctx = ctx } + +func (a *App) GetBills() WailsBills { + res := WailsBills{} + bills, err := service.GetAllBills() + if err != nil { + res.Success = false + res.Error = err.Error() + return res + } + res.Success = true + res.Data = bills + return res +} +func (a *App) GetPaymentsForMonth(month time.Time) WailsPayments { + res := WailsPayments{} + payments, err := service.GetPaymentsForDate(month) + if err != nil { + res.Success = false + res.Error = err.Error() + return res + } + res.Success = true + res.Data = payments + return res +} +func (a *App) SetPaid(billid int64, month time.Time) WailsPayment { + res := WailsPayment{} + payment, err := service.MarkPaid(billid, month, time.Now()) + if err!= nil { + res.Success = false + res.Error = err.Error() + return res + } + res.Success = true + res.Data = payment + return res +} \ No newline at end of file diff --git a/db.go b/db.go new file mode 100644 index 0000000..cc74f08 --- /dev/null +++ b/db.go @@ -0,0 +1,134 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "os" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +type DB struct { + Ready bool + path string + readConn *sql.DB + writeConn *sql.DB +} + +func (db *DB) Open() error { + if db.path == "" { + return fmt.Errorf("database path not set") + } + + file, err := os.Open(db.path) + if err != nil { + if os.IsNotExist(err) { + log.Printf("Database file does not exist at %s, creating", db.path) + file, err := os.Create(db.path) + if err != nil { + return fmt.Errorf("failed to create database file: %v", err) + } + log.Printf("Database created at %s", db.path) + file.Close() + } else { + return fmt.Errorf("failed to open database file: %v", err) + } + } + file.Close() + + writeConn, err := sql.Open("sqlite3", db.path+"?_journal=WAL&_synchronous=NORMAL") + if err != nil { + Error.Printf("%++v", err) + return err + } + writeConn.SetMaxOpenConns(1) + writeConn.SetConnMaxIdleTime(30 * time.Second) + writeConn.SetConnMaxLifetime(30 * time.Second) + db.writeConn = writeConn + + readConn, err := sql.Open("sqlite3", db.path+"?mode=ro&_journal=WAL&_synchronous=NORMAL&_mode=ro") + if err != nil { + Error.Printf("%++v", err) + return err + } + readConn.SetMaxOpenConns(4) + readConn.SetConnMaxIdleTime(30 * time.Second) + readConn.SetConnMaxLifetime(30 * time.Second) + db.readConn = readConn + + db.Ready = true + return nil +} + +func (db *DB) Init(ddl string) error { + if !db.Ready { + return fmt.Errorf("database not ready") + } + + var rows map[string]struct{} = make(map[string]struct{}) + // TODO: Maybe make this better one day... + var expected = map[string]struct{}{ + "Bill": {}, + "Payment": {}, + } + + res, err := db.readConn.Query("SELECT name FROM sqlite_master WHERE type='table';") + if err != nil { + Error.Printf("%++v", err) + return err + } + defer res.Close() + for res.Next() { + var name string + err := res.Scan(&name) + if err != nil { + Error.Printf("%++v", err) + return err + } + rows[name] = struct{}{} + } + + var needsInit bool + for table := range expected { + if _, ok := rows[table]; !ok { + log.Printf("Table %s not found, initializing", table) + needsInit = true + break + } + } + + if !needsInit { + log.Printf("Database already initialized") + return nil + } + + _, err = db.writeConn.Exec(ddl) + if err != nil { + Error.Printf("%++v", err) + log.Printf("%#v", "Rolling back") + _, err2 := db.writeConn.Exec("ROLLBACK;") + if err2 != nil { + Error.Printf("Error rolling back! %++v", err) + } + return err + } + + log.Printf("Database init OK") + return nil +} + +func (db *DB) Close() error { + err := db.writeConn.Close() + if err != nil { + return err + } + + err = db.readConn.Close() + if err != nil { + return err + } + + return nil +} \ No newline at end of file diff --git a/ddl.sql b/ddl.sql new file mode 100644 index 0000000..465f587 --- /dev/null +++ b/ddl.sql @@ -0,0 +1,19 @@ +begin transaction; + +create table Bill ( + id integer PRIMARY KEY AUTOINCREMENT, + name TEXT not null +); +create index Bill_name on Bill (name); + +create table Payment ( + id integer PRIMARY KEY AUTOINCREMENT, + billid integer, + monthFor date not null, + paymentDate date +); +create unique index Payment_billid_monthFor_unique on Payment (billid, monthFor); +create index Payment_billid on Payment (billid); +create index Payment_monthFor on Payment (monthFor); + +commit; \ No newline at end of file diff --git a/main.go b/main.go index f672882..fc460da 100644 --- a/main.go +++ b/main.go @@ -2,22 +2,66 @@ package main import ( "embed" + "fmt" + "io" + "log" + "os" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" ) +var Error *log.Logger +var Warning *log.Logger + +func init() { + log.SetFlags(log.Lmicroseconds | log.Lshortfile) + logFile, err := os.Create("main.log") + if err != nil { + log.Printf("Error creating log file: %v", err) + os.Exit(1) + } + logger := io.MultiWriter(os.Stdout, logFile) + log.SetOutput(logger) + + Error = log.New(io.MultiWriter(logFile, os.Stderr, os.Stdout), + fmt.Sprintf("%sERROR:%s ", "\033[0;101m", "\033[0m"), + log.Lmicroseconds|log.Lshortfile) + Warning = log.New(io.MultiWriter(logFile, os.Stdout), + fmt.Sprintf("%sWarning:%s ", "\033[0;93m", "\033[0m"), + log.Lmicroseconds|log.Lshortfile) +} + //go:embed all:frontend/dist var assets embed.FS +//go:embed ddl.sql +var ddl string +var service *BillService + func main() { + db := DB{ + path: "bills.db", + } + db.Open() + defer db.Close() + err := db.Init(ddl) + if err != nil { + Error.Printf("Error initializing database: %v", err) + return + } + + service = &BillService{ + db: &db, + } + // Create an instance of the app structure app := NewApp() // Create application with options - err := wails.Run(&options.App{ - Title: "wails-template", + err = wails.Run(&options.App{ + Title: "bill-manager-w", Width: 1024, Height: 768, AssetServer: &assetserver.Options{ diff --git a/service.go b/service.go new file mode 100644 index 0000000..6691f42 --- /dev/null +++ b/service.go @@ -0,0 +1,131 @@ +package main + +import ( + "fmt" + "time" +) + +type BillService struct { + db *DB + bills map[int64]Bill +} + +const paymentColumns = "id, billid, monthFor, paymentDate" +const billColumns = "id, name" + +func (s *BillService) GetPaymentsForDate(date time.Time) ([]Payment, error) { + res := []Payment{} + if s == nil { + return res, fmt.Errorf("calling GetPaymentsFor on nil BillService") + } + if s.db == nil || !s.db.Ready { + return res, fmt.Errorf("cannot get payments, db is nil or not ready - %v", s.db) + } + + rows, err := s.db.readConn.Query(fmt.Sprintf(` +SELECT %s +FROM Payment +WHERE monthFor = date(strftime('%%Y-%%m-01', ?)); +`, paymentColumns), date) + if err != nil { + return res, fmt.Errorf("query for payments of %s failed with error: %v", date, err) + } + + for rows.Next() { + payment := Payment{} + err = rows.Scan(&payment.Id, &payment.BillId, &payment.MonthFor, &payment.PaymentDate) + if err != nil { + Error.Printf("failed to scan row: %v", err) + continue + } + res = append(res, payment) + } + + return res, nil +} + +func (s *BillService) GetPaymentForBillAndDate(billid int64, date time.Time) (Payment, error) { + res := Payment{} + if s == nil { + return res, fmt.Errorf("calling GetPaymentsFor on nil BillService") + } + if s.db == nil || !s.db.Ready { + return res, fmt.Errorf("cannot get payments, db is nil or not ready - %v", s.db) + } + + row := s.db.readConn.QueryRow(fmt.Sprintf(` +SELECT %s +FROM Payment +WHERE billid = ? AND monthFor = date(strftime('%%Y-%%m-01', ?)); +`, paymentColumns), billid, date) + + err := row.Scan(&res.Id, &res.BillId, &res.MonthFor, &res.PaymentDate) + if err != nil { + return res, fmt.Errorf("failed scanning row: %v", err) + } + + return res, nil +} + +func (s *BillService) GetAllBills() ([]Bill, error) { + res := []Bill{} + if s == nil { + return res, fmt.Errorf("calling GetAllBills on nil BillService") + } + if s.db == nil || !s.db.Ready { + return res, fmt.Errorf("cannot get bills, db is nil or not ready - %v", s.db) + } + + rows, err := s.db.readConn.Query(fmt.Sprintf(`SELECT %s FROM Bill ORDER BY name`, billColumns)) + if err != nil { + return res, fmt.Errorf("failed to query for bills: %w", err) + } + + for rows.Next() { + bill := Bill{} + err := rows.Scan(&bill.Id, &bill.Name) + if err != nil { + Error.Printf("failed to scan row: %v", err) + continue + } + res = append(res, bill) + } + + s.bills = make(map[int64]Bill) + for _, bill := range res { + s.bills[bill.Id] = bill + } + + return res, nil +} + +func (s *BillService) MarkPaid(billid int64, monthFor time.Time, when time.Time) (Payment, error) { + res := Payment{} + if s == nil { + return res, fmt.Errorf("calling MarkPaid on nil BillService") + } + if s.db == nil || !s.db.Ready { + return res, fmt.Errorf("cannot mark bill paid, db is nil or not ready - %v", s.db) + } + + qres, err := s.db.writeConn.Exec(` +INSERT INTO Payment (billid, monthFor, paymentDate) +VALUES (?, date(strftime('%Y-%m-01', ?)), ?) +ON CONFLICT(billid, monthFor) DO UPDATE SET + paymentDate = excluded.paymentDate +WHERE Payment.paymentDate IS NULL + `, billid, monthFor, when) + if err != nil { + return res, fmt.Errorf("failed upserting into payment with error: %w", err) + } + + rows, err := qres.RowsAffected() + if err != nil { + return res, fmt.Errorf("failed to get rows affected: %w", err) + } + if rows == 0 { + return res, fmt.Errorf("no rows affected") + } + + return s.GetPaymentForBillAndDate(billid, monthFor) +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..8b9d8d1 --- /dev/null +++ b/types.go @@ -0,0 +1,32 @@ +package main + +import "time" + +type ( + Bill struct { + Id int64 + Name string + } + Payment struct { + Id int64 + BillId int64 + MonthFor time.Time + PaymentDate time.Time + } + + WailsBills struct { + Data []Bill + Success bool + Error string + } + WailsPayments struct { + Data []Payment + Success bool + Error string + } + WailsPayment struct { + Data Payment + Success bool + Error string + } +)