diff --git a/.agents/instructions.md b/.agents/instructions.md new file mode 100644 index 0000000..3e41bfb --- /dev/null +++ b/.agents/instructions.md @@ -0,0 +1,28 @@ +# Agent Role +You are an expert full-stack and IoT software engineer assisting with the "UniCard" open-source project. + +# Project Context +UniCard is a closed-loop contactless payment system for retail and public transportation. +**Crucial Distinction:** While this system integrates with physical hardware (ESP32 & RFID RC522 terminals), the software component you are building is a **full web application**, not just embedded logic. + +# The Tech Stack +* **Backend:** Go (Golang) using standard library `net/http` where possible. +* **Frontend:** HTML, JS. Tailwind CSS. +* **Database:** MySQL. +* **Hardware Interface:** ESP32 sending payloads to the Go backend. + +# Architecture & Coding Standards +1. **Backend Structure:** Enforce clean code architecture. Use modular packages and strict dependency injection. +2. **Version Control:** All work must follow Gitflow standards. Write commit messages using conventional commit naming (e.g., `feat: add transaction handler`, `fix: resolve db race condition`). +3. **Modularity:** Keep functions small. Do not create massive monolithic files. +4. **Error Handling:** Implement robust logging, especially for database transactions and hardware-to-server HTTP requests. + +# Core Business Logic Rules +* Support two payment modes: Itemized Retail and Distance-based Transport (jeepneys, buses, tricycles). +* Automatically apply a 20% discount for eligible users (PWD/Students). +* Automatically calculate and apply a 0.2% cashback point reward per transaction. +* Trigger email receipts via Gmail SMTP upon successful transactions. + +# Workflow Directives +* When asked to build a feature, start by scaffolding the directory structure or proposing the database schema before writing the implementation logic. +* If a bug occurs, analyze the provided terminal or browser console error logs before proposing a fix. \ No newline at end of file diff --git a/.agents/rules/unicard.md b/.agents/rules/unicard.md index 513a1d3..7541ced 100644 --- a/.agents/rules/unicard.md +++ b/.agents/rules/unicard.md @@ -1,6 +1,28 @@ --- trigger: always_on -glob: -description: --- +# Agent Role & Context +You are an expert full-stack and IoT software engineer assisting with "UniCard," an open-source, closed-loop contactless payment system. +You are building the **software web application** side of the ecosystem (not the embedded C++ hardware code, unless explicitly asked). + +# Tech Stack & Guidelines +- **Backend (Go):** Use standard library `net/http`. Enforce clean code architecture, modular packages, and strict dependency injection. +- **Frontend (Vue.js):** Use Vue 3, Vite, and Tailwind CSS. Build responsive, reusable components. +- **Database (MySQL):** Write optimized, secure queries. Handle financial transaction states strictly. +- **Hardware Integration:** The system receives payloads from ESP32/RFID RC522 terminals. Ensure secure and robust API endpoints to handle these payloads. + +# Coding Standards +1. **No Monoliths:** Keep functions small and modular. Break large files down. +2. **Version Control:** Adhere strictly to Gitflow. Use conventional commit messages (e.g., `feat:`, `fix:`, `refactor:`). +3. **Error Handling:** Implement robust logging for database transactions and hardware-to-server HTTP requests. Do not swallow errors. + +# Core Business Logic Directives +- **Dual Modes:** Support Retail (itemized) and Transport (distance-based) payment flows. +- **Automated Calculations:** Apply a 20% discount for eligible users (PWD/Students) and calculate a 0.2% cashback point reward per transaction. +- **Notifications:** Trigger email receipts via Gmail SMTP upon successful transactions. + +# Workflow Rules +- **Analyze First:** Before proposing new code, read the relevant existing files to understand the current architecture. +- **Step-by-Step:** Do not output massive blocks of code across multiple files at once. Propose the architecture/schema first, wait for approval, then implement. +- **Debug Method:** When provided with an error log, analyze the stack trace and explain the root cause before writing the fix. \ No newline at end of file diff --git a/.gitignore b/.gitignore index 70b6e0f..d5c81b1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,7 @@ storage/**/* !storage/**/.gitkeep tested/* -tested/**/* \ No newline at end of file +tested/**/* + +testdata/* +testdata/**/* \ No newline at end of file diff --git a/backend/cmd/app/main.go b/backend/cmd/app/main.go index ec6e5df..74e3e9c 100644 --- a/backend/cmd/app/main.go +++ b/backend/cmd/app/main.go @@ -1,7 +1,6 @@ package main import ( - "database/sql" "fmt" "html/template" "log" @@ -11,15 +10,14 @@ import ( authentication "unicard-go/backend/internal/auth" "unicard-go/backend/internal/merchant" "unicard-go/backend/internal/middleware" + "unicard-go/backend/internal/pkg/database" "unicard-go/backend/internal/user" - _ "github.com/go-sql-driver/mysql" "github.com/joho/godotenv" ) var ( tpl *template.Template - db *sql.DB ) func main() { @@ -35,13 +33,6 @@ func main() { // read .env VALUES port := os.Getenv("PORT") serverAddress := os.Getenv("SERVER_PORT") - dbUser := os.Getenv("DB_USER") - dbPass := os.Getenv("DB_PASSWORD") - dbName := os.Getenv("DB_NAME") - dbHost := os.Getenv("DB_HOST") - dbPort := os.Getenv("DB_PORT") - // Enable parseTime and set location so MySQL DATETIME/TIMESTAMP scan into time.Time - dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&loc=Local", dbUser, dbPass, dbHost, dbPort, dbName) // Setup Templates tpl, err = template.ParseGlob("./frontend/templates/*/*.html") @@ -49,23 +40,20 @@ func main() { log.Fatalf("Failed to load templates: %v. Check your folder path.", err) } - // Setup Database - db, err = sql.Open("mysql", dsn) + // Setup Database using the new database package + db, err := database.Connect() if err != nil { - panic(err.Error()) + log.Fatalf("Failed to connect to database: %v", err) } defer db.Close() - // Always verify connection - if err := db.Ping(); err != nil { - panic("Database connection failed: " + err.Error()) - } + store := database.NewStore(db) // Initialize the Handler from the auth package - authHandler := authentication.NewHandler(db, tpl) - adminHanlder := admin.NewHandler(db, tpl) - userHandler := user.NewHandler(db, tpl) - merchantHandler := merchant.NewHandler(db, tpl) + authHandler := authentication.NewHandler(store, tpl) + adminHanlder := admin.NewHandler(store, tpl) + userHandler := user.NewHandler(store, tpl) + merchantHandler := merchant.NewHandler(store, tpl) // Setup Router mux := http.NewServeMux() diff --git a/backend/internal/admin/add_card.go b/backend/internal/admin/add_card.go index 39ff2b9..6efe690 100644 --- a/backend/internal/admin/add_card.go +++ b/backend/internal/admin/add_card.go @@ -17,6 +17,8 @@ import ( "github.com/go-playground/validator/v10" ) +const cardType = "regular" // Lowercase to match your database ENUM + // AddCardsView renders the addCards.html template after checking the admin session. func (h *Handler) AddCardsView(w http.ResponseWriter, r *http.Request) { fmt.Println("AddCardsView running...") @@ -70,8 +72,8 @@ func (h *Handler) AddCardHandler(w http.ResponseWriter, r *http.Request) { // Auto-generate data cardNumber := h.generateCardNumber() - cardType := "regular" // Lowercase to match your database ENUM - expiryDate := time.Now().AddDate(2, 0, 0).Format("2006-01-02") // 2 years from now unlinked + // Set card to be unlinked for 2 years + expiryDate := time.Now().AddDate(2, 0, 0).Format("2006-01-02") // Check for existing UID uidExists, err := h.cardUIDExist(cardUID) @@ -97,7 +99,7 @@ func (h *Handler) AddCardHandler(w http.ResponseWriter, r *http.Request) { VALUES (?, ?, ?, ?, ?, 'inactive') ` - _, err = h.DB.Exec( + _, err = h.Store.Exec( query, cardUID, cardNumber, @@ -127,7 +129,7 @@ func (h *Handler) AddCardHandler(w http.ResponseWriter, r *http.Request) { func (h *Handler) cardUIDExist(uid string) (bool, error) { var existingUID string query := "SELECT card_uid FROM cards WHERE card_uid = ?" - err := h.DB.QueryRow(query, uid).Scan(&existingUID) + err := h.Store.QueryRow(query, uid).Scan(&existingUID) if err == sql.ErrNoRows { return false, nil } else if err != nil { diff --git a/backend/internal/admin/add_merchant.go b/backend/internal/admin/add_merchant.go index 999f338..e07ea8e 100644 --- a/backend/internal/admin/add_merchant.go +++ b/backend/internal/admin/add_merchant.go @@ -14,6 +14,8 @@ import ( "github.com/go-playground/validator/v10" "golang.org/x/crypto/bcrypt" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) // AddMerchantRequest represents the payload for adding a new merchant @@ -52,7 +54,7 @@ func (h *Handler) AddMerchantHandler(w http.ResponseWriter, r *http.Request) { return } - tx, err := h.DB.Begin() + tx, err := h.Store.Begin() if err != nil { log.Printf("Error starting tx: %v", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ @@ -105,18 +107,19 @@ func (h *Handler) AddMerchantHandler(w http.ResponseWriter, r *http.Request) { defer termStmt.Close() for i, req := range reqs { + caser := cases.Title(language.English) // Clean and format string fields - req.BusinessName = strings.Title(strings.ToLower(strings.TrimSpace(req.BusinessName))) - req.BusinessAddress = strings.Title(strings.ToLower(strings.TrimSpace(req.BusinessAddress))) - req.OwnerName = strings.Title(strings.ToLower(strings.TrimSpace(req.OwnerName))) - req.SettlementName = strings.Title(strings.ToLower(strings.TrimSpace(req.SettlementName))) - + req.BusinessName = caser.String(strings.ToLower(strings.TrimSpace(req.BusinessName))) + req.BusinessAddress = caser.String(strings.ToLower(strings.TrimSpace(req.BusinessAddress))) + req.OwnerName = caser.String(strings.ToLower(strings.TrimSpace(req.OwnerName))) + req.SettlementName = caser.String(strings.ToLower(strings.TrimSpace(req.SettlementName))) + // Some fields don't need title case but should be trimmed req.BusinessEmail = strings.ToLower(strings.TrimSpace(req.BusinessEmail)) req.BusinessPhone = strings.TrimSpace(req.BusinessPhone) req.TerminalSN = strings.TrimSpace(req.TerminalSN) req.DeviceName = strings.TrimSpace(req.DeviceName) - + reqs[i] = req // update back to slice err := Validate.Struct(req) diff --git a/backend/internal/admin/admin_dashboard.go b/backend/internal/admin/admin_dashboard.go index 32e0944..5ff4168 100644 --- a/backend/internal/admin/admin_dashboard.go +++ b/backend/internal/admin/admin_dashboard.go @@ -5,6 +5,8 @@ import ( "net/http" jsonwrite "unicard-go/backend/internal/pkg/handler" structs "unicard-go/backend/internal/pkg/structs" + + "github.com/shopspring/decimal" ) type AdminPageData struct { @@ -30,9 +32,9 @@ func (h *Handler) AdminDashboardDataHandler(w http.ResponseWriter, r *http.Reque // Compute UniCard's Absolute Gross Revenue // (Transaction Service Fees) query := `SELECT COALESCE(SUM(service_fee), 0.00) FROM transactions WHERE transaction_type IN ('payment', 'topup', 'withdrawal')` - row := h.DB.QueryRow(query) + row := h.Store.QueryRow(query) - var grossRevenue float64 + var grossRevenue decimal.Decimal if err := row.Scan(&grossRevenue); err != nil { log.Println("Error scanning gross revenue:", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ @@ -53,9 +55,9 @@ func (h *Handler) AdminDashboardDataHandler(w http.ResponseWriter, r *http.Reque END ), 0.00) FROM transactions` - row = h.DB.QueryRow(query) + row = h.Store.QueryRow(query) - var netRevenue float64 + var netRevenue decimal.Decimal if err := row.Scan(&netRevenue); err != nil { log.Println("Error scanning net revenue:", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ @@ -68,7 +70,7 @@ func (h *Handler) AdminDashboardDataHandler(w http.ResponseWriter, r *http.Reque // Display the number of users // Counting only 'active' customers (excluding suspended or inactive accounts) - row = h.DB.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'customer'") // status = 'active' + row = h.Store.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'customer'") // status = 'active' var totalUsers int if err := row.Scan(&totalUsers); err != nil { @@ -83,7 +85,7 @@ func (h *Handler) AdminDashboardDataHandler(w http.ResponseWriter, r *http.Reque // Display the number of cards // Counting only 'active' cards to show actual circulatory supply - row = h.DB.QueryRow("SELECT COUNT(*) FROM cards") // WHERE status = 'active' + row = h.Store.QueryRow("SELECT COUNT(*) FROM cards") // WHERE status = 'active' var totalCards int if err := row.Scan(&totalCards); err != nil { @@ -97,7 +99,7 @@ func (h *Handler) AdminDashboardDataHandler(w http.ResponseWriter, r *http.Reque log.Println("Total cards row:", totalCards) // Display the number of merchants and breakdown - row = h.DB.QueryRow(` + row = h.Store.QueryRow(` SELECT COUNT(*), COALESCE(SUM(CASE WHEN status = 'pending_approval' THEN 1 ELSE 0 END), 0), @@ -118,7 +120,7 @@ func (h *Handler) AdminDashboardDataHandler(w http.ResponseWriter, r *http.Reque log.Println("Total merchants:", totalMerchants, "Pending:", pendingMerchants) // Display the number of terminals and breakdown - row = h.DB.QueryRow(` + row = h.Store.QueryRow(` SELECT COUNT(*), COALESCE(SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END), 0), @@ -139,7 +141,7 @@ func (h *Handler) AdminDashboardDataHandler(w http.ResponseWriter, r *http.Reque // Fetch recent merchants for the table (limit 5) merchantQuery := "SELECT merchant_id, business_name, business_type, owner_name, business_email, business_phone, status, created_at FROM merchants ORDER BY created_at DESC LIMIT 5" - rows, err := h.DB.Query(merchantQuery) + rows, err := h.Store.Query(merchantQuery) if err != nil { log.Println("Error querying merchants:", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ diff --git a/backend/internal/admin/admin_merchant.go b/backend/internal/admin/admin_merchant.go index 5f5be56..6b335b1 100644 --- a/backend/internal/admin/admin_merchant.go +++ b/backend/internal/admin/admin_merchant.go @@ -77,7 +77,7 @@ func (h *Handler) MerchantManagementDataHandler(w http.ResponseWriter, r *http.R // Count total items countQuery := `SELECT COUNT(*) ` + baseQuery + whereClause var totalItems int - if err := h.DB.QueryRow(countQuery, args...).Scan(&totalItems); err != nil { + if err := h.Store.QueryRow(countQuery, args...).Scan(&totalItems); err != nil { log.Println("Error counting merchants:", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ Success: false, @@ -101,7 +101,7 @@ func (h *Handler) MerchantManagementDataHandler(w http.ResponseWriter, r *http.R args = append(args, limit, offset) - rows, err := h.DB.Query(query, args...) + rows, err := h.Store.Query(query, args...) if err != nil { log.Println("Error querying merchants:", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ @@ -143,7 +143,7 @@ func (h *Handler) MerchantManagementDataHandler(w http.ResponseWriter, r *http.R JOIN merchants m ON t.merchant_id = m.merchant_id WHERE m.merchant_id IN (%s)`, strings.Join(placeholders, ",")) - termRows, err := h.DB.Query(termQuery, termArgs...) + termRows, err := h.Store.Query(termQuery, termArgs...) if err == nil { defer termRows.Close() termMap := make(map[string][]structs.Terminal) @@ -211,7 +211,7 @@ func (h *Handler) ApproveMerchantHandler(w http.ResponseWriter, r *http.Request) } // Begin TX - tx, err := h.DB.Begin() + tx, err := h.Store.Begin() if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Database error"}) return @@ -386,7 +386,7 @@ func (h *Handler) RejectMerchantHandler(w http.ResponseWriter, r *http.Request) return } - tx, err := h.DB.Begin() + tx, err := h.Store.Begin() if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Database error"}) return @@ -467,7 +467,7 @@ func (h *Handler) SuspendMerchantHandler(w http.ResponseWriter, r *http.Request) return } - tx, err := h.DB.Begin() + tx, err := h.Store.Begin() if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Database error"}) return @@ -536,7 +536,7 @@ func (h *Handler) DeleteMerchantHandler(w http.ResponseWriter, r *http.Request) return } - tx, err := h.DB.Begin() + tx, err := h.Store.Begin() if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Database error"}) return @@ -672,7 +672,7 @@ func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request // ADDED: Nullable types for address, phone, and type to prevent NULL crashes var setBank, setName, setAcct, regNum, dtiDoc, birDoc, otherDoc, city, postal, docStatus, busAddress, busPhone, busType sql.NullString - err := h.DB.QueryRow(` + err := h.Store.QueryRow(` SELECT merchant_id, user_id, business_name, business_type, business_registration_number, business_address, city, postal_code, owner_name, business_email, business_phone, status, commission_rate, settlement_bank_name, settlement_account_name, @@ -751,7 +751,7 @@ func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request WHERE merchant_id = ? ` - termRows, err := h.DB.Query(termQuery, merchantID) + termRows, err := h.Store.Query(termQuery, merchantID) if err != nil { log.Println("Error querying merchant terminals:", err) m.Terminals = []structs.Terminal{} diff --git a/backend/internal/admin/admin_terminal.go b/backend/internal/admin/admin_terminal.go index 716fe5b..83c82d3 100644 --- a/backend/internal/admin/admin_terminal.go +++ b/backend/internal/admin/admin_terminal.go @@ -14,6 +14,18 @@ import ( structs "unicard-go/backend/internal/pkg/structs" ) +// AddTerminalRequest payload +type AddTerminalRequest struct { + TerminalSN string `json:"terminalSn" validate:"required"` + DeviceName string `json:"deviceName" validate:"required"` +} + +type UnassignedTerminalData struct { + TerminalSN string `json:"terminal_sn"` + DeviceName string `json:"device_name"` + Status string `json:"status"` +} + func (h *Handler) TerminalRegistryView(w http.ResponseWriter, r *http.Request) { log.Println("TerminalRegistryView running...") data := AdminPageData{ @@ -68,7 +80,7 @@ func (h *Handler) TerminalRegistryDataHandler(w http.ResponseWriter, r *http.Req // Count total items countQuery := `SELECT COUNT(*) ` + baseQuery + whereClause var totalItems int - if err := h.DB.QueryRow(countQuery, args...).Scan(&totalItems); err != nil { + if err := h.Store.QueryRow(countQuery, args...).Scan(&totalItems); err != nil { log.Println("Error counting terminals:", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ Success: false, @@ -87,7 +99,7 @@ func (h *Handler) TerminalRegistryDataHandler(w http.ResponseWriter, r *http.Req args = append(args, limit, offset) - rows, err := h.DB.Query(query, args...) + rows, err := h.Store.Query(query, args...) if err != nil { log.Println("Error querying terminals:", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ @@ -112,8 +124,8 @@ func (h *Handler) TerminalRegistryDataHandler(w http.ResponseWriter, r *http.Req } var activeCount, inactiveCount int - h.DB.QueryRow(`SELECT COUNT(*) FROM terminals WHERE status IN ('active', 'online')`).Scan(&activeCount) - h.DB.QueryRow(`SELECT COUNT(*) FROM terminals WHERE status IN ('inactive', 'offline')`).Scan(&inactiveCount) + h.Store.QueryRow(`SELECT COUNT(*) FROM terminals WHERE status IN ('active', 'online')`).Scan(&activeCount) + h.Store.QueryRow(`SELECT COUNT(*) FROM terminals WHERE status IN ('inactive', 'offline')`).Scan(&inactiveCount) type PaginatedTerminalResponse struct { Terminals []structs.Terminal `json:"terminals"` @@ -141,12 +153,6 @@ func (h *Handler) TerminalRegistryDataHandler(w http.ResponseWriter, r *http.Req log.Println("TerminalRegistryDataHandler finished") } -// AddTerminalRequest payload -type AddTerminalRequest struct { - TerminalSN string `json:"terminalSn" validate:"required"` - DeviceName string `json:"deviceName" validate:"required"` -} - // AddTerminalHandler registers a new standalone terminal func (h *Handler) AddTerminalHandler(w http.ResponseWriter, r *http.Request) { var req AddTerminalRequest @@ -173,7 +179,7 @@ func (h *Handler) AddTerminalHandler(w http.ResponseWriter, r *http.Request) { // Insert into DB with NULL merchant_id query := `INSERT INTO terminals (terminal_id, terminal_sn, merchant_id, device_name, status) VALUES (?, ?, NULL, ?, 'inactive')` - _, err := h.DB.Exec(query, terminalID, req.TerminalSN, req.DeviceName) + _, err := h.Store.Exec(query, terminalID, req.TerminalSN, req.DeviceName) if err != nil { log.Printf("Error inserting standalone terminal: %v", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ @@ -189,14 +195,8 @@ func (h *Handler) AddTerminalHandler(w http.ResponseWriter, r *http.Request) { }) } -type UnassignedTerminalData struct { - TerminalSN string `json:"terminal_sn"` - DeviceName string `json:"device_name"` - Status string `json:"status"` -} - func (h *Handler) GetUnassignedTerminalsHandler(w http.ResponseWriter, r *http.Request) { - rows, err := h.DB.Query(` + rows, err := h.Store.Query(` SELECT terminal_sn, device_name, status FROM terminals WHERE merchant_id IS NULL AND status = 'inactive' diff --git a/backend/internal/admin/card_inventory.go b/backend/internal/admin/card_inventory.go index 9c844a5..7a682e8 100644 --- a/backend/internal/admin/card_inventory.go +++ b/backend/internal/admin/card_inventory.go @@ -5,18 +5,20 @@ import ( "fmt" "net/http" jsonwrite "unicard-go/backend/internal/pkg/handler" + + "github.com/shopspring/decimal" ) // AdminCard represents a card entry in the admin database type AdminCard struct { - UserID sql.NullString `json:"user_id" db:"user_id"` - CardUID string `json:"card_uid" db:"card_uid"` - CardNumber string `json:"card_number" db:"card_number"` - CardType string `json:"card_type" db:"card_type"` - Balance float64 `json:"initial_amount" db:"balance"` - ExpiryDate string `json:"expiry_date" db:"expiry_date"` - Status string `json:"status" db:"status"` - CreatedAt string `json:"created_at" db:"created_at"` + UserID sql.NullString `json:"user_id" db:"user_id"` + CardUID string `json:"card_uid" db:"card_uid"` + CardNumber string `json:"card_number" db:"card_number"` + CardType string `json:"card_type" db:"card_type"` + Balance decimal.Decimal `json:"initial_amount" db:"balance"` + ExpiryDate string `json:"expiry_date" db:"expiry_date"` + Status string `json:"status" db:"status"` + CreatedAt string `json:"created_at" db:"created_at"` } // AdminCardInventoryStats contains statistics about cards @@ -47,14 +49,14 @@ func (h *Handler) CardInventoryDataHandler(w http.ResponseWriter, r *http.Reques // Fetch Stats var stats AdminCardInventoryStats - h.DB.QueryRow("SELECT COUNT(*) FROM cards").Scan(&stats.Total) - h.DB.QueryRow("SELECT COUNT(*) FROM cards WHERE status = 'Active'").Scan(&stats.Active) - h.DB.QueryRow("SELECT COUNT(*) FROM cards WHERE status = 'Inactive'").Scan(&stats.Inactive) - h.DB.QueryRow("SELECT COUNT(*) FROM cards WHERE status = 'Blocked'").Scan(&stats.Blocked) - h.DB.QueryRow("SELECT COUNT(*) FROM cards WHERE status = 'Lost'").Scan(&stats.Lost) + h.Store.QueryRow("SELECT COUNT(*) FROM cards").Scan(&stats.Total) + h.Store.QueryRow("SELECT COUNT(*) FROM cards WHERE status = 'Active'").Scan(&stats.Active) + h.Store.QueryRow("SELECT COUNT(*) FROM cards WHERE status = 'Inactive'").Scan(&stats.Inactive) + h.Store.QueryRow("SELECT COUNT(*) FROM cards WHERE status = 'Blocked'").Scan(&stats.Blocked) + h.Store.QueryRow("SELECT COUNT(*) FROM cards WHERE status = 'Lost'").Scan(&stats.Lost) // Fetch Cards - rows, err := h.DB.Query(` + rows, err := h.Store.Query(` SELECT user_id, card_uid, card_number, card_type, balance, status, expiry_date, created_at FROM cards ORDER BY created_at DESC @@ -113,7 +115,7 @@ func (h *Handler) BlockCardHandler(w http.ResponseWriter, r *http.Request) { return } - result, err := h.DB.Exec(` + result, err := h.Store.Exec(` UPDATE cards SET status = 'Blocked' WHERE card_number = ? OR card_uid = ? diff --git a/backend/internal/admin/deactivate_card.go b/backend/internal/admin/deactivate_card.go index 37dc142..b6e014f 100644 --- a/backend/internal/admin/deactivate_card.go +++ b/backend/internal/admin/deactivate_card.go @@ -97,7 +97,7 @@ func (h *Handler) DeactivateCardHanlder(w http.ResponseWriter, r *http.Request) // --- Helper functions --- func (h *Handler) deactivateCardIfActive(cardNumber, cardHolder, cardType string) (bool, error) { - result, err := h.DB.Exec(` + result, err := h.Store.Exec(` UPDATE cards SET status = 'Blocked' WHERE card_number = ? diff --git a/backend/internal/admin/delete_card.go b/backend/internal/admin/delete_card.go index a07669d..a4b93e3 100644 --- a/backend/internal/admin/delete_card.go +++ b/backend/internal/admin/delete_card.go @@ -58,7 +58,7 @@ func (h *Handler) DeleteCardHandler(w http.ResponseWriter, r *http.Request) { } // Delete from the database - result, err := h.DB.Exec("DELETE FROM cards WHERE card_number = ?", cardNumber) + result, err := h.Store.Exec("DELETE FROM cards WHERE card_number = ?", cardNumber) if err != nil { fmt.Println("Error deleting card from DB:", err) jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ diff --git a/backend/internal/admin/handler.go b/backend/internal/admin/handler.go index 538a3db..a0433e1 100644 --- a/backend/internal/admin/handler.go +++ b/backend/internal/admin/handler.go @@ -1,20 +1,20 @@ package admin import ( - "database/sql" "html/template" + "unicard-go/backend/internal/pkg/database" ) // The struct is shared across the files in this package type Handler struct { - DB *sql.DB // Database connection - Tpl *template.Template // HTML templates + Store database.Store // Database store + Tpl *template.Template // HTML templates } // Optional: A constructor to make initialization cleaner -func NewHandler(db *sql.DB, tpl *template.Template) *Handler { +func NewHandler(store database.Store, tpl *template.Template) *Handler { return &Handler{ - DB: db, - Tpl: tpl, + Store: store, + Tpl: tpl, } } diff --git a/backend/internal/admin/terminal_requests.go b/backend/internal/admin/terminal_requests.go index c5284c3..ffc38ed 100644 --- a/backend/internal/admin/terminal_requests.go +++ b/backend/internal/admin/terminal_requests.go @@ -36,6 +36,15 @@ type TerminalRequestsResponse struct { TotalPages int `json:"total_pages,omitempty"` } +type ApproveTerminalRequestPayload struct { + AssignTerminalSN string `json:"assign_terminal_sn"` + Notes string `json:"notes"` +} + +type RejectTerminalRequestPayload struct { + Reason string `json:"reason"` +} + func (h *Handler) TerminalRequestsView(w http.ResponseWriter, r *http.Request) { log.Println("TerminalRequestsView running...") data := AdminPageData{ @@ -68,7 +77,7 @@ func (h *Handler) TerminalRequestsDataHandler(w http.ResponseWriter, r *http.Req // Check if table exists first checkTableQuery := `SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'unicard' AND TABLE_NAME = 'terminal_requests' LIMIT 1` var tableExists int - err := h.DB.QueryRow(checkTableQuery).Scan(&tableExists) + err := h.Store.QueryRow(checkTableQuery).Scan(&tableExists) if err != nil { log.Printf("Table check error: %v", err) // Return empty list if table doesn't exist @@ -111,7 +120,7 @@ func (h *Handler) TerminalRequestsDataHandler(w http.ResponseWriter, r *http.Req // Count total items countQuery := `SELECT COUNT(*) ` + baseQuery + whereClause var totalItems int - if err := h.DB.QueryRow(countQuery, args...).Scan(&totalItems); err != nil { + if err := h.Store.QueryRow(countQuery, args...).Scan(&totalItems); err != nil { log.Printf("Count query error: %v | Query: %s | Args: %v", err, countQuery, args) jsonwrite.WriteJSON(w, http.StatusInternalServerError, map[string]interface{}{ "success": false, @@ -131,7 +140,7 @@ func (h *Handler) TerminalRequestsDataHandler(w http.ResponseWriter, r *http.Req args = append(args, limit, offset) - rows, err := h.DB.Query(dataQuery, args...) + rows, err := h.Store.Query(dataQuery, args...) if err != nil { log.Printf("Data query error: %v | Query: %s | Args: %v", err, dataQuery, args) jsonwrite.WriteJSON(w, http.StatusInternalServerError, map[string]interface{}{ @@ -219,11 +228,6 @@ func (h *Handler) TerminalRequestsDataHandler(w http.ResponseWriter, r *http.Req jsonwrite.WriteJSON(w, http.StatusOK, response) } -type ApproveTerminalRequestPayload struct { - AssignTerminalSN string `json:"assign_terminal_sn"` - Notes string `json:"notes"` -} - func (h *Handler) ApproveTerminalRequestHandler(w http.ResponseWriter, r *http.Request) { log.Println("ApproveTerminalRequestHandler running...") adminUserID := r.PathValue("username") @@ -247,7 +251,7 @@ func (h *Handler) ApproveTerminalRequestHandler(w http.ResponseWriter, r *http.R } // Start transaction - tx, err := h.DB.Begin() + tx, err := h.Store.Begin() if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, map[string]interface{}{ "success": false, @@ -295,7 +299,7 @@ func (h *Handler) ApproveTerminalRequestHandler(w http.ResponseWriter, r *http.R if assignTerminalSN == "" && terminalSN != nil { assignTerminalSN = *terminalSN } - + if assignTerminalSN == "" { jsonwrite.WriteJSON(w, http.StatusBadRequest, map[string]interface{}{ "success": false, @@ -350,7 +354,7 @@ func (h *Handler) ApproveTerminalRequestHandler(w http.ResponseWriter, r *http.R // Get merchant address for location details var businessAddress, city string err := tx.QueryRow(`SELECT business_address, city FROM merchants WHERE merchant_id = ?`, merchantID).Scan(&businessAddress, &city) - + locationDetails := "" if err == nil { if city != "" { @@ -359,7 +363,7 @@ func (h *Handler) ApproveTerminalRequestHandler(w http.ResponseWriter, r *http.R locationDetails = businessAddress } } - + _, err = tx.Exec( `UPDATE terminals SET merchant_id = ?, location_details = ?, status = 'active' WHERE terminal_sn = ?`, merchantID, @@ -396,10 +400,6 @@ func (h *Handler) ApproveTerminalRequestHandler(w http.ResponseWriter, r *http.R }) } -type RejectTerminalRequestPayload struct { - Reason string `json:"reason"` -} - func (h *Handler) RejectTerminalRequestHandler(w http.ResponseWriter, r *http.Request) { log.Println("RejectTerminalRequestHandler running...") adminUserID := r.PathValue("username") @@ -424,7 +424,7 @@ func (h *Handler) RejectTerminalRequestHandler(w http.ResponseWriter, r *http.Re // Check current status var currentStatus string - err := h.DB.QueryRow( + err := h.Store.QueryRow( `SELECT status FROM terminal_requests WHERE request_id = ?`, requestID, ).Scan(¤tStatus) @@ -458,7 +458,7 @@ func (h *Handler) RejectTerminalRequestHandler(w http.ResponseWriter, r *http.Re reason = "Rejected by admin" } - _, err = h.DB.Exec( + _, err = h.Store.Exec( `UPDATE terminal_requests SET status = 'rejected', handled_by = ?, handled_at = CURRENT_TIMESTAMP, notes = ? WHERE request_id = ?`, adminUserID, reason, diff --git a/backend/internal/admin/terminal_sim.go b/backend/internal/admin/terminal_sim.go index 7511a62..996e1f2 100644 --- a/backend/internal/admin/terminal_sim.go +++ b/backend/internal/admin/terminal_sim.go @@ -4,24 +4,37 @@ import ( "database/sql" "encoding/json" "fmt" + "log" "net/http" "time" - "github.com/shopspring/decimal" jsonwrite "unicard-go/backend/internal/pkg/handler" + + "github.com/shopspring/decimal" ) +type Merchant struct { + ID string + Name string +} + +type SimRequest struct { + CardNumber string `json:"card_number"` + Type string `json:"type"` + Amount decimal.Decimal `json:"amount"` + MerchantID string `json:"merchant_id"` + Balance decimal.Decimal `json:"balance"` + Status string `json:"status"` + UserID *string `json:"user_id"` +} + // TerminalSimView renders the terminal simulation page func (h *Handler) TerminalSimView(w http.ResponseWriter, r *http.Request) { fmt.Println("Terminal Simulation view is running...") - type Merchant struct { - ID string - Name string - } var merchants []Merchant - rows, err := h.DB.Query("SELECT merchant_id, business_name FROM merchants") + rows, err := h.Store.Query("SELECT merchant_id, business_name FROM merchants") if err == nil { defer rows.Close() for rows.Next() { @@ -45,13 +58,6 @@ func (h *Handler) TerminalSimView(w http.ResponseWriter, r *http.Request) { func (h *Handler) TerminalSimTransactionHandler(w http.ResponseWriter, r *http.Request) { fmt.Println("TerminalSimTransactionHandler is running...") - type SimRequest struct { - CardNumber string `json:"card_number"` - Type string `json:"type"` - Amount float64 `json:"amount"` - MerchantID string `json:"merchant_id"` - } - var req SimRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ @@ -61,7 +67,7 @@ func (h *Handler) TerminalSimTransactionHandler(w http.ResponseWriter, r *http.R return } - if req.CardNumber == "" || req.Amount <= 0 || req.Type == "" { + if req.CardNumber == "" || req.Amount.LessThanOrEqual(decimal.Zero) || req.Type == "" { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ Success: false, Message: "Missing required fields", @@ -69,16 +75,13 @@ func (h *Handler) TerminalSimTransactionHandler(w http.ResponseWriter, r *http.R return } - // 1. Check if card exists, is active, and linked to a user - var balance float64 - var status string - var userID sql.NullString + // Check if card exists, is active, and linked to a user - err := h.DB.QueryRow(` + err := h.Store.QueryRow(` SELECT balance, status, user_id FROM cards WHERE card_number = ? - `, req.CardNumber).Scan(&balance, &status, &userID) + `, req.CardNumber).Scan(&req.Balance, &req.Status, &req.UserID) if err != nil { if err == sql.ErrNoRows { @@ -95,7 +98,7 @@ func (h *Handler) TerminalSimTransactionHandler(w http.ResponseWriter, r *http.R return } - if status != "active" { + if req.Status != "active" { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ Success: false, Message: "Card is not active", @@ -103,7 +106,7 @@ func (h *Handler) TerminalSimTransactionHandler(w http.ResponseWriter, r *http.R return } - if !userID.Valid || userID.String == "" { + if req.UserID == nil || *req.UserID == "" { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ Success: false, Message: "Card is not linked to any user", @@ -111,29 +114,31 @@ func (h *Handler) TerminalSimTransactionHandler(w http.ResponseWriter, r *http.R return } - // 2. Check balance - if req.Type != "Refund" && balance < req.Amount { - jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + // Check balance + if req.Type != "Refund" && req.Balance.LessThan(req.Amount) { + log.Printf("Insufficient Balance : %s, req Amount: %s", req.Balance, req.Amount) + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ Success: false, - Message: fmt.Sprintf("Insufficient balance. Current balance: %.2f", balance), + Message: "Insufficient balance", }) return } - // 2.5 Get merchant commission rate - var commissionRate float64 - err = h.DB.QueryRow("SELECT commission_rate FROM merchants WHERE merchant_id = ?", req.MerchantID).Scan(&commissionRate) + // Get merchant commission rate + var commissionRate decimal.Decimal + err = h.Store.QueryRow("SELECT commission_rate FROM merchants WHERE merchant_id = ?", req.MerchantID).Scan(&commissionRate) if err != nil { - commissionRate = 2.00 // default fallback + commissionRate = decimal.NewFromFloat(2) // default fallback } - serviceFee := req.Amount * (commissionRate / 100.0) + // Calculate service fee + serviceFee := req.Amount.Mul(commissionRate.Div(decimal.NewFromFloat(100))) - amountDec := decimal.NewFromFloat(req.Amount) - loyaltyPoints := amountDec.Mul(decimal.NewFromFloat(0.002)) + // Calculate loyalty points + loyaltyPoints := req.Amount.Mul(decimal.NewFromFloat(0.002)) - // 3. Process Transaction (Start TX) - tx, err := h.DB.Begin() + // Process Transaction (Start TX) + tx, err := h.Store.Begin() if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ Success: false, @@ -162,14 +167,14 @@ func (h *Handler) TerminalSimTransactionHandler(w http.ResponseWriter, r *http.R // Get a dummy terminal ID var terminalID string - err = h.DB.QueryRow("SELECT terminal_id FROM terminals LIMIT 1").Scan(&terminalID) + err = h.Store.QueryRow("SELECT terminal_id FROM terminals LIMIT 1").Scan(&terminalID) if err != nil { terminalID = "TRM-SIM-001" // Fallback if no terminals exist } // Get a dummy processed_by user ID var processedBy string - err = h.DB.QueryRow("SELECT user_id FROM users LIMIT 1").Scan(&processedBy) + err = h.Store.QueryRow("SELECT user_id FROM users LIMIT 1").Scan(&processedBy) if err != nil { processedBy = "USR-SIM-001" // Fallback if no users exist } diff --git a/backend/internal/admin/transactions.go b/backend/internal/admin/transactions.go index 2203719..525e644 100644 --- a/backend/internal/admin/transactions.go +++ b/backend/internal/admin/transactions.go @@ -49,7 +49,7 @@ func (h *Handler) AllTransactionsJSONHandler(w http.ResponseWriter, r *http.Requ LEFT JOIN merchants m ON t.merchant_id = m.merchant_id ORDER BY t.created_at DESC ` - rows, err := h.DB.Query(txnQuery) + rows, err := h.Store.Query(txnQuery) type TxnResponse struct { TransactionID string `json:"transaction_id"` diff --git a/backend/internal/auth/admin_signup.go b/backend/internal/auth/admin_signup.go index 5b995b3..cbf02fe 100644 --- a/backend/internal/auth/admin_signup.go +++ b/backend/internal/auth/admin_signup.go @@ -72,7 +72,7 @@ func (h *Handler) AdminSignupHandler(w http.ResponseWriter, r *http.Request) { (user_id, username, name, email, password_hash, role, status) VALUES (?, ?, ?, ?, ?, 'super_admin', 'active')` - _, err = h.DB.ExecContext(ctx, insertQuery, userIDStr, req.Username, req.Name, req.Email, hashedPassword) + _, err = h.Store.ExecContext(ctx, insertQuery, userIDStr, req.Username, req.Name, req.Email, hashedPassword) if err != nil { log.Printf("Error creating admin user: %v", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ diff --git a/backend/internal/auth/forgotPassword.go b/backend/internal/auth/forgotPassword.go index 239bfd2..a47d491 100644 --- a/backend/internal/auth/forgotPassword.go +++ b/backend/internal/auth/forgotPassword.go @@ -100,7 +100,7 @@ func (h *Handler) ForgotPasswordSendOTP(w http.ResponseWriter, r *http.Request) return } - exists, err := account.IsEmailExist(h.DB, req.Email) + exists, err := account.IsEmailExist(h.Store.DB(), req.Email) if err != nil { log.Println("Error checking email existence:", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ @@ -120,7 +120,7 @@ func (h *Handler) ForgotPasswordSendOTP(w http.ResponseWriter, r *http.Request) // Fetch the user's name var fullName string - err = h.DB.QueryRowContext(ctx, "SELECT name FROM users WHERE email = ?", req.Email).Scan(&fullName) + err = h.Store.QueryRowContext(ctx, "SELECT name FROM users WHERE email = ?", req.Email).Scan(&fullName) if err != nil { fullName = "there" // Fallback if name is not found } @@ -273,7 +273,7 @@ func validatePassword(password string) error { // Update Password Handler func (h *Handler) updatePassword(email, hashedPassword string) error { query := "UPDATE users SET password_hash = ? WHERE email = ?" - _, err := h.DB.Exec(query, hashedPassword, email) + _, err := h.Store.Exec(query, hashedPassword, email) if err != nil { log.Printf("failed to update password: %v", err) return err diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go index e88da00..f357e83 100644 --- a/backend/internal/auth/handler.go +++ b/backend/internal/auth/handler.go @@ -1,20 +1,20 @@ package authentication import ( - "database/sql" "html/template" + "unicard-go/backend/internal/pkg/database" ) // The struct is shared across the files in this package type Handler struct { - DB *sql.DB // Database connection - Tpl *template.Template // HTML templates + Store database.Store // Database store + Tpl *template.Template // HTML templates } // Optional: A constructor to make initialization cleaner -func NewHandler(db *sql.DB, tpl *template.Template) *Handler { +func NewHandler(store database.Store, tpl *template.Template) *Handler { return &Handler{ - DB: db, - Tpl: tpl, + Store: store, + Tpl: tpl, } } diff --git a/backend/internal/auth/login.go b/backend/internal/auth/login.go index 1ed277d..a73856f 100644 --- a/backend/internal/auth/login.go +++ b/backend/internal/auth/login.go @@ -83,7 +83,7 @@ func (h *Handler) LoginAuthHandler(w http.ResponseWriter, r *http.Request) { ) stmt := "SELECT id, username, password_hash, role FROM users WHERE email = ? OR username = ? OR phone_number = ?" - err = h.DB.QueryRow(stmt, loginReq.Identifier, loginReq.Identifier, loginReq.Identifier).Scan(&ID, &userName, &hash, &role) + err = h.Store.QueryRow(stmt, loginReq.Identifier, loginReq.Identifier, loginReq.Identifier).Scan(&ID, &userName, &hash, &role) // User not found if err != nil { @@ -130,10 +130,10 @@ func (h *Handler) LoginAuthHandler(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "jwt", Value: accessToken, - Expires: time.Now().Add(1 * time.Minute), // 15 minutes expiration + Expires: time.Now().Add(15 * time.Minute), // 15 minutes expiration HttpOnly: true, Secure: true, // Important for SameSite=StrictMode - SameSite: http.SameSiteStrictMode, + SameSite: http.SameSiteLaxMode, Path: "/", }) @@ -141,10 +141,10 @@ func (h *Handler) LoginAuthHandler(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "refresh_token", Value: refreshToken, - Expires: time.Now().Add(7 * 24 * time.Hour), // 7 days expiration + Expires: time.Now().Add(24 * time.Hour), // 24 hours expiration HttpOnly: true, Secure: true, // Important for SameSite=StrictMode - SameSite: http.SameSiteStrictMode, + SameSite: http.SameSiteLaxMode, Path: "/", }) diff --git a/backend/internal/auth/merchant_signup.go b/backend/internal/auth/merchant_signup.go index c4d5d8b..0b3e061 100644 --- a/backend/internal/auth/merchant_signup.go +++ b/backend/internal/auth/merchant_signup.go @@ -117,7 +117,7 @@ func (h *Handler) MerchantSignupHandler(w http.ResponseWriter, r *http.Request) } // Check if email already exists - exists, err := account.IsEmailExist(h.DB, req.BusinessEmail) + exists, err := account.IsEmailExist(h.Store.DB(), req.BusinessEmail) if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ Success: false, @@ -146,7 +146,7 @@ func (h *Handler) MerchantSignupHandler(w http.ResponseWriter, r *http.Request) // Begin transaction ctx := r.Context() - tx, err := h.DB.BeginTx(ctx, nil) + tx, err := h.Store.BeginTx(ctx, nil) if err != nil { log.Printf("Error starting transaction: %v", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ diff --git a/backend/internal/auth/repository.go b/backend/internal/auth/repository.go index 57f4982..55c033c 100644 --- a/backend/internal/auth/repository.go +++ b/backend/internal/auth/repository.go @@ -10,7 +10,7 @@ import ( func (h *Handler) isUserIDExist(userID int64) (bool, error) { var tmpId int64 query := "SELECT user_id FROM users WHERE user_id = ?" - err := h.DB.QueryRow(query, userID).Scan(&tmpId) + err := h.Store.QueryRow(query, userID).Scan(&tmpId) if err == sql.ErrNoRows { return false, nil // Doesn't exist! @@ -28,7 +28,7 @@ func (h *Handler) GetInitialBalance(cardNumber string) (float64, error) { var initialBalance float64 // to hold the initial balance query := "SELECT balance FROM cards WHERE card_number = ?" - err := h.DB.QueryRow(query, cardNumber).Scan(&initialBalance) + err := h.Store.QueryRow(query, cardNumber).Scan(&initialBalance) if err != nil { log.Printf("GetInitialBalance error for card %s: %v", cardNumber, err) return 0, err @@ -46,7 +46,7 @@ func (h *Handler) isPhoneExist(phone string) (bool, error) { // Check query query := "SELECT phone_number FROM users WHERE phone_number = ?" - err := h.DB.QueryRow(query, phone).Scan(&existingPhone) + err := h.Store.QueryRow(query, phone).Scan(&existingPhone) if err == sql.ErrNoRows { return false, nil } diff --git a/backend/internal/auth/signup.go b/backend/internal/auth/signup.go index ae48bf6..cc71796 100644 --- a/backend/internal/auth/signup.go +++ b/backend/internal/auth/signup.go @@ -1,8 +1,7 @@ package authentication import ( - //"database/sql" - + "database/sql" "encoding/json" "errors" "fmt" @@ -125,7 +124,7 @@ func (h *Handler) SignupSendOTP(w http.ResponseWriter, r *http.Request) { } // Check Email - exists, err := account.IsEmailExist(h.DB, req.Email) + exists, err := account.IsEmailExist(h.Store.DB(), req.Email) if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ Success: false, @@ -235,7 +234,7 @@ func (h *Handler) CheckCardHandler(w http.ResponseWriter, r *http.Request) { } var status string - err := h.DB.QueryRow("SELECT status FROM cards WHERE card_number = ?", req.CardNumber).Scan(&status) + err := h.Store.QueryRow("SELECT status FROM cards WHERE card_number = ?", req.CardNumber).Scan(&status) if err != nil { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ Success: false, @@ -384,63 +383,45 @@ func (h *Handler) SignupHandler(w http.ResponseWriter, r *http.Request) { } // Begin transaction: insert user + activate card atomically - tx, err := h.DB.BeginTx(ctx, nil) - if err != nil { - log.Printf("Error starting transaction: %v", err) - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "System error starting transaction.", - }) - return - } - defer tx.Rollback() // no-op if tx.Commit() is called - - // Insert User - insertQuery := `INSERT INTO users - (user_id, username, name, email, phone_number, password_hash, role, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - _, err = tx.ExecContext(ctx, insertQuery, - user.UserID, user.Username, user.Name, user.Email, user.Phone, - user.Password, user.Role, user.Status, user.CreatedAt, - ) - if err != nil { - log.Printf("Error inserting user: %v", err) - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "System error creating account. Please try again.", - }) - return - } - log.Printf("User record inserted: %v", user.Name) - - // Activate Card and Link User Details - // Set expiry date to 5 years from now in expiry_date column - updateCardQuery := ` - UPDATE cards - SET status = 'active', - user_id = ?, - card_type = 'regular', - linked_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP, - expiry_date = DATE_ADD(CURRENT_DATE, INTERVAL 5 YEAR) - WHERE card_number = ?` - - _, err = tx.ExecContext(ctx, updateCardQuery, user.UserID, user.CardNumber) + err = h.Store.ExecTx(ctx, func(tx *sql.Tx) error { + // Insert User + insertQuery := `INSERT INTO users + (user_id, username, name, email, phone_number, password_hash, role, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + _, err := tx.ExecContext(ctx, insertQuery, + user.UserID, user.Username, user.Name, user.Email, user.Phone, + user.Password, user.Role, user.Status, user.CreatedAt, + ) + if err != nil { + log.Printf("Error inserting user: %v", err) + return fmt.Errorf("system error creating account") + } + log.Printf("User record inserted: %v", user.Name) + + // Activate Card and Link User Details + updateCardQuery := ` + UPDATE cards + SET status = 'active', + user_id = ?, + card_type = 'regular', + linked_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP, + expiry_date = DATE_ADD(CURRENT_DATE, INTERVAL 5 YEAR) + WHERE card_number = ?` + + _, err = tx.ExecContext(ctx, updateCardQuery, user.UserID, user.CardNumber) + if err != nil { + log.Printf("Error activating card for card_number %s: %v", user.CardNumber, err) + return fmt.Errorf("system error activating card") + } - if err != nil { - log.Printf("Error activating card for card_number %s: %v", user.CardNumber, err) - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "System error activating card.", - }) - return - } + return nil + }) - if err = tx.Commit(); err != nil { - log.Printf("Error committing transaction: %v", err) + if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ Success: false, - Message: "System error finalizing account creation.", + Message: "System error finalizing account creation. Please try again.", }) return } diff --git a/backend/internal/merchant/account.go b/backend/internal/merchant/account.go index 441b61d..d08caba 100644 --- a/backend/internal/merchant/account.go +++ b/backend/internal/merchant/account.go @@ -13,6 +13,9 @@ import ( jsonwrite "unicard-go/backend/internal/pkg/handler" ) +// set the max file size to 8MB +const maxUploadSize = 8 << 20 + // Struct to hold data for the merchant account page type BusinessDetails struct { BusinessName string `json:"business_name"` @@ -65,7 +68,8 @@ type MerchantDetails struct { accNumber string `db:"settlement_account_number"` businessDoc string `db:"business_document,bir_document"` birDoc string `db:"bir_document"` - otherDoc string `db:"other_document"` + validID string `db:"other_document"` // valid_id + bankDoc string `db:"bank_document"` // docStatus string `db:"document_status"` docMessage string `db:"message"` createdAtStr string `db:"created_at"` @@ -85,6 +89,8 @@ func (h *Handler) MerchantAccountView(w http.ResponseWriter, r *http.Request) { } } +// get merchant profile, merchant details, merchant bank details, and merchant documents +// merchantDocuments has a limit of 3, and the order depends on the order of the files uploaded func (h *Handler) MerchantAccountDataHandler(w http.ResponseWriter, r *http.Request) { log.Println("MerchantAccountDataHandler running...") @@ -102,7 +108,7 @@ func (h *Handler) MerchantAccountDataHandler(w http.ResponseWriter, r *http.Requ var merchant MerchantDetails // Execute the full JOIN query - err := h.DB.QueryRowContext(ctx, ` + err := h.Store.QueryRowContext(ctx, ` SELECT m.merchant_id, COALESCE(m.status, ''), @@ -123,7 +129,8 @@ func (h *Handler) MerchantAccountDataHandler(w http.ResponseWriter, r *http.Requ -- Document Info COALESCE(m.business_document, ''), COALESCE(m.bir_document, ''), - COALESCE(m.other_document, ''), + COALESCE(m.valid_id, ''), + COALESCE(m.bank_document, ''), COALESCE(m.document_status, ''), COALESCE(m.message, '') FROM merchants m @@ -134,7 +141,7 @@ func (h *Handler) MerchantAccountDataHandler(w http.ResponseWriter, r *http.Requ &merchant.businessType, &merchant.businessEmail, &merchant.businessPhone, &merchant.businessAddress, &merchant.city, &merchant.postalCode, &merchant.accName, &merchant.bankName, &merchant.accNumber, &merchant.businessDoc, - &merchant.birDoc, &merchant.otherDoc, &merchant.docStatus, &merchant.docMessage, + &merchant.birDoc, &merchant.validID, &merchant.bankDoc, &merchant.docStatus, &merchant.docMessage, ) if err != nil { @@ -149,6 +156,7 @@ func (h *Handler) MerchantAccountDataHandler(w http.ResponseWriter, r *http.Requ // Format UI Logic memberSince := merchant.createdAtStr + // mask bank account number with asterisks except the last 4 digits var maskedAccount string if len(merchant.accNumber) > 4 { maskedAccount = "**** **** **** " + merchant.accNumber[len(merchant.accNumber)-4:] @@ -182,13 +190,22 @@ func (h *Handler) MerchantAccountDataHandler(w http.ResponseWriter, r *http.Requ }) } - // Other Document - if merchant.otherDoc != "" { + // Valid Government ID (PhilHealth, SSS, Pag-IBIG) + if merchant.validID != "" { + documents = append(documents, BusinessDocument{ + DocumentType: "Valid Government ID", + Status: merchant.docStatus, + Message: merchant.docMessage, + DocumentURL: merchant.validID, + }) + } + + if merchant.bankDoc != "" { documents = append(documents, BusinessDocument{ - DocumentType: "Other Document", + DocumentType: "Bank Document", Status: merchant.docStatus, Message: merchant.docMessage, - DocumentURL: merchant.otherDoc, + DocumentURL: merchant.bankDoc, }) } @@ -224,6 +241,9 @@ func (h *Handler) MerchantAccountDataHandler(w http.ResponseWriter, r *http.Requ }) } +// UpdateBankDetails function will update the bank details of the merchant +// it will update the database and set the document status to pending and the message to "Bank details updated successfully" +// if the bank details are updated successfully func (h *Handler) UpdateBankDetails(w http.ResponseWriter, r *http.Request) { log.Println("UpdateBankDetails running...") username := r.PathValue("username") @@ -232,31 +252,41 @@ func (h *Handler) UpdateBankDetails(w http.ResponseWriter, r *http.Request) { return } + // decode the request body into the BusinessBankDetails struct var req BusinessBankDetails if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Invalid request payload"}) return } + // check if all bank details fields are not empty if strings.TrimSpace(req.BankName) == "" || strings.TrimSpace(req.AccountHolderName) == "" || strings.TrimSpace(req.AccountNumber) == "" { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "All bank details fields are required"}) return } + // check if the channel code of the bank is valid if _, validBank := channelCodeMap[req.BankName]; !validBank { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Unsupported bank selected. Please choose a valid bank from the list."}) return } + // get the merchant ID and existing account number from the database var merchantID string - err := h.DB.QueryRow("SELECT merchant_id FROM merchants WHERE user_id = (SELECT user_id FROM users WHERE username=?)", username).Scan(&merchantID) + var existingAccNumber string + err := h.Store.QueryRow("SELECT merchant_id, settlement_account_number FROM merchants WHERE user_id = (SELECT user_id FROM users WHERE username=?)", username).Scan(&merchantID, &existingAccNumber) if err != nil { log.Println("Error finding merchant for update:", err) jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant not found"}) return } - _, err = h.DB.Exec("UPDATE merchants SET settlement_bank_name=?, settlement_account_name=?, settlement_account_number=? WHERE merchant_id = ?", req.BankName, req.AccountHolderName, req.AccountNumber, merchantID) + // Prevent overwriting with masked account number + if strings.Contains(req.AccountNumber, "****") { + req.AccountNumber = existingAccNumber + } + + _, err = h.Store.Exec("UPDATE merchants SET settlement_bank_name=?, settlement_account_name=?, settlement_account_number=? WHERE merchant_id = ?", req.BankName, req.AccountHolderName, req.AccountNumber, merchantID) if err != nil { log.Println("Update error:", err) @@ -266,7 +296,7 @@ func (h *Handler) UpdateBankDetails(w http.ResponseWriter, r *http.Request) { // Insert a system transaction to log the update sysTxnID := fmt.Sprintf("SYS-SETTLE-%d", time.Now().UnixMilli()) - _, _ = h.DB.Exec(` + _, _ = h.Store.Exec(` INSERT INTO transactions (transaction_id, merchant_id, transaction_type, amount, points_earned, service_fee, status, description) VALUES (?, ?, 'payment', NULL, NULL, NULL, 'completed', 'Settlement bank details were updated by the merchant.')`, @@ -275,6 +305,9 @@ func (h *Handler) UpdateBankDetails(w http.ResponseWriter, r *http.Request) { jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{Success: true, Message: "Bank details updated"}) } +// UploadDocument function will upload the business documents to the server and update the database +// it will set the document status to pending and the message to "Document uploaded successfully" +// if the document is uploaded successfully func (h *Handler) UploadDocument(w http.ResponseWriter, r *http.Request) { log.Println("UploadDocument running...") username := r.PathValue("username") @@ -283,7 +316,7 @@ func (h *Handler) UploadDocument(w http.ResponseWriter, r *http.Request) { return } - err := r.ParseMultipartForm(4 << 20) // Limit memory to 4MB + err := r.ParseMultipartForm(maxUploadSize) if err != nil { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "File too large"}) return @@ -297,7 +330,7 @@ func (h *Handler) UploadDocument(w http.ResponseWriter, r *http.Request) { } defer file.Close() - if handler.Size > 4*1024*1024 { + if handler.Size > maxUploadSize { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "File too large. Maximum size is 4MB."}) return } @@ -310,7 +343,8 @@ func (h *Handler) UploadDocument(w http.ResponseWriter, r *http.Request) { ".pdf": true, } if !validExts[ext] { - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Invalid file format. Only pictures, PDF, and Word docs are allowed."}) + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, Message: "Invalid file format. Only pictures, PDF, and Word docs are allowed."}) return } @@ -337,31 +371,45 @@ func (h *Handler) UploadDocument(w http.ResponseWriter, r *http.Request) { dbPath := "/" + strings.ReplaceAll(filePath, "\\", "/") - col := "business_document" + column := "business_document" switch docType { case "BIR Certificate": - col = "bir_document" - case "Other Document": - col = "other_document" + column = "bir_document" + case "Valid ID": + column = "valid_id" + case "Bank Document": + column = "bank_document" } // Remove old file if it exists var oldDbPath *string - qOld := fmt.Sprintf("SELECT %s FROM merchants WHERE user_id = (SELECT user_id FROM users WHERE username=?)", col) - if err := h.DB.QueryRow(qOld, username).Scan(&oldDbPath); err == nil && oldDbPath != nil { + qOld := fmt.Sprintf(` + SELECT %s FROM merchants + WHERE user_id = (SELECT user_id FROM users WHERE username=?)`, + column) + if err := h.Store.QueryRow(qOld, username).Scan(&oldDbPath); err == nil && oldDbPath != nil { oldFile := strings.TrimPrefix(*oldDbPath, "/") if oldFile != "" { - os.Remove(oldFile) // Best effort delete + if err := os.Remove(oldFile); err != nil { + log.Println("Error removing old file:", err) + } + log.Println("Old file removed:", oldFile) } } - query := fmt.Sprintf("UPDATE merchants SET %s=?, document_status='Pending' WHERE user_id = (SELECT user_id FROM users WHERE username=?)", col) - _, err = h.DB.Exec(query, dbPath, username) + query := fmt.Sprintf(` + UPDATE merchants SET %s=?, document_status='Pending' + WHERE user_id = (SELECT user_id FROM users WHERE username=?)`, + column) + _, err = h.Store.Exec(query, dbPath, username) if err != nil { log.Println("DB update error:", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to update DB"}) return } - jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{Success: true, Message: "File uploaded successfully"}) + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, + Message: "File uploaded successfully", + }) } diff --git a/backend/internal/merchant/dashboard.go b/backend/internal/merchant/dashboard.go index 961cb7c..51a34c2 100644 --- a/backend/internal/merchant/dashboard.go +++ b/backend/internal/merchant/dashboard.go @@ -66,7 +66,7 @@ func (h *Handler) GetMerchantAccountInfo(ctx context.Context, merchantID string) log.Println("GetMerchantAccountInfo running...") var accountStatus, accountRole string - err := h.DB.QueryRowContext(ctx, ` + err := h.Store.QueryRowContext(ctx, ` SELECT u.status, u.role FROM users u JOIN merchants m ON u.user_id = m.user_id @@ -83,7 +83,7 @@ func (h *Handler) GetMerchantAccountInfo(ctx context.Context, merchantID string) func (h *Handler) GetMerchantRecentTransactions(ctx context.Context, merchantID string) ([]MerchantTransaction, error) { log.Println("GetMerchantRecentTransactions running...") - rows, err := h.DB.QueryContext(ctx, ` + rows, err := h.Store.QueryContext(ctx, ` SELECT transaction_id, COALESCE(card_number, ''), merchant_id, terminal_id, @@ -146,7 +146,7 @@ func (h *Handler) MerchantDashboardDataHandler(w http.ResponseWriter, r *http.Re // Resolve merchant_id from username var merchantID string var settlementBank, settlementAccount *string - err := h.DB.QueryRowContext(ctx, ` + err := h.Store.QueryRowContext(ctx, ` SELECT m.merchant_id, m.settlement_bank_name, m.settlement_account_number FROM merchants m JOIN users u ON m.user_id = u.user_id @@ -175,7 +175,7 @@ func (h *Handler) MerchantDashboardDataHandler(w http.ResponseWriter, r *http.Re // Count ALL transactions regardless of type var totalTransactions int - err = h.DB.QueryRowContext(ctx, ` + err = h.Store.QueryRowContext(ctx, ` SELECT COUNT(*) FROM transactions WHERE merchant_id = ?`, @@ -189,9 +189,9 @@ func (h *Handler) MerchantDashboardDataHandler(w http.ResponseWriter, r *http.Re return } - // Revenue summary — payments only + // Revenue summary — payments only var totalRevenue, totalServiceFee, totalIncome decimal.Decimal - err = h.DB.QueryRowContext(ctx, ` + err = h.Store.QueryRowContext(ctx, ` SELECT COALESCE(SUM(amount), 0), COALESCE(SUM(service_fee), 0), @@ -216,7 +216,7 @@ func (h *Handler) MerchantDashboardDataHandler(w http.ResponseWriter, r *http.Re // Refunds total var totalRefunds decimal.Decimal - err = h.DB.QueryRowContext(ctx, ` + err = h.Store.QueryRowContext(ctx, ` SELECT COALESCE(SUM(amount), 0) FROM transactions WHERE merchant_id = ? diff --git a/backend/internal/merchant/handler.go b/backend/internal/merchant/handler.go index e38e97f..fb91b6b 100644 --- a/backend/internal/merchant/handler.go +++ b/backend/internal/merchant/handler.go @@ -1,20 +1,20 @@ package merchant import ( - "database/sql" "html/template" + "unicard-go/backend/internal/pkg/database" ) // The struct is shared across the files in this package type Handler struct { - DB *sql.DB // Database connection - Tpl *template.Template // HTML templates + Store database.Store // Database store + Tpl *template.Template // HTML templates } // Optional: A constructor to make initialization cleaner -func NewHandler(db *sql.DB, tpl *template.Template) *Handler { +func NewHandler(store database.Store, tpl *template.Template) *Handler { return &Handler{ - DB: db, - Tpl: tpl, + Store: store, + Tpl: tpl, } } diff --git a/backend/internal/merchant/incomes.go b/backend/internal/merchant/incomes.go index 280fbc2..057f0f9 100644 --- a/backend/internal/merchant/incomes.go +++ b/backend/internal/merchant/incomes.go @@ -47,7 +47,7 @@ func (h *Handler) GetMerchantIncomeStats(ctx context.Context, merchantID string) var totalCollected, unicardFee, totalEarned, totalRefunded, earnedThisMonth, refundedThisMonth, totalWithdrawn, withdrawnThisMonth decimal.Decimal - err := h.DB.QueryRowContext(ctx, ` + err := h.Store.QueryRowContext(ctx, ` SELECT COALESCE(SUM(CASE WHEN transaction_type = 'payment' THEN amount ELSE 0 END), 0), COALESCE(SUM(CASE WHEN transaction_type = 'payment' THEN service_fee ELSE 0 END), 0), @@ -108,7 +108,7 @@ func (h *Handler) GetMerchantIncomeStats(ctx context.Context, merchantID string) func (h *Handler) GetMerchantIncomeHistory(ctx context.Context, merchantID string) ([]IncomeHistory, error) { log.Println("GetMerchantIncomeHistory running...") - rows, err := h.DB.QueryContext(ctx, ` + rows, err := h.Store.QueryContext(ctx, ` SELECT COALESCE(created_at, ''), description, transaction_id, COALESCE(card_number, ''), @@ -163,7 +163,7 @@ func (h *Handler) IncomeHandler(w http.ResponseWriter, r *http.Request) { // Resolve merchant_id from username var merchantID string - err := h.DB.QueryRowContext(ctx, ` + err := h.Store.QueryRowContext(ctx, ` SELECT m.merchant_id FROM merchants m JOIN users u ON m.user_id = u.user_id diff --git a/backend/internal/merchant/terminal_request.go b/backend/internal/merchant/terminal_request.go index afa0833..3bab061 100644 --- a/backend/internal/merchant/terminal_request.go +++ b/backend/internal/merchant/terminal_request.go @@ -30,7 +30,7 @@ func (h *Handler) RequestTerminalHandler(w http.ResponseWriter, r *http.Request) // resolve merchant_id var merchantID string - err := h.DB.QueryRow("SELECT merchant_id FROM merchants WHERE user_id = (SELECT user_id FROM users WHERE username = ?)", username).Scan(&merchantID) + err := h.Store.QueryRow("SELECT merchant_id FROM merchants WHERE user_id = (SELECT user_id FROM users WHERE username = ?)", username).Scan(&merchantID) if err != nil { log.Println("Error finding merchant for terminal request:", err) jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant not found"}) @@ -40,7 +40,7 @@ func (h *Handler) RequestTerminalHandler(w http.ResponseWriter, r *http.Request) // generate request id requestID := fmt.Sprintf("TRQ-%d", time.Now().UnixNano()/1000000) - _, err = h.DB.Exec("INSERT INTO terminal_requests (request_id, merchant_id, terminal_sn, status, requested_at, notes) VALUES (?, ?, ?, 'pending', CURRENT_TIMESTAMP, ?)", requestID, merchantID, payload.TerminalSN, payload.Notes) + _, err = h.Store.Exec("INSERT INTO terminal_requests (request_id, merchant_id, terminal_sn, status, requested_at, notes) VALUES (?, ?, ?, 'pending', CURRENT_TIMESTAMP, ?)", requestID, merchantID, payload.TerminalSN, payload.Notes) if err != nil { log.Println("Failed to create terminal request:", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to create terminal request"}) diff --git a/backend/internal/merchant/transactions.go b/backend/internal/merchant/transactions.go index 7f7958c..34055ef 100644 --- a/backend/internal/merchant/transactions.go +++ b/backend/internal/merchant/transactions.go @@ -37,7 +37,7 @@ func (h *Handler) TransactionHandler(w http.ResponseWriter, r *http.Request) { // Resolve merchant_id from username var merchantID string - err := h.DB.QueryRowContext(ctx, ` + err := h.Store.QueryRowContext(ctx, ` SELECT m.merchant_id FROM merchants m JOIN users u ON m.user_id = u.user_id @@ -89,7 +89,7 @@ func (h *Handler) TransactionHandler(w http.ResponseWriter, r *http.Request) { query += ` LIMIT 100` - rows, err := h.DB.QueryContext(ctx, query, args...) + rows, err := h.Store.QueryContext(ctx, query, args...) if err != nil { log.Println("Error fetching transactions:", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ diff --git a/backend/internal/merchant/withdraw.go b/backend/internal/merchant/withdraw.go index 273b41d..2a9dc52 100644 --- a/backend/internal/merchant/withdraw.go +++ b/backend/internal/merchant/withdraw.go @@ -51,7 +51,7 @@ type BankDetails struct { } type WithdrawRequest struct { - Amount float64 `json:"amount"` + Amount decimal.Decimal `json:"amount"` } // WithdrawHandler handles the merchant's request to withdraw their available balance. @@ -78,17 +78,17 @@ func (h *Handler) WithdrawHandler(w http.ResponseWriter, r *http.Request) { return } - if req.Amount <= 0 { + if req.Amount.LessThanOrEqual(decimal.Zero) { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ Success: false, - Message: "Withdrawal amount must be greater than zero", + Message: "Withdrawal amount must be greater than ₱0.00", }) return } // Fetch Merchant Info (ID, Settlement Details) var bank BankDetails - err := h.DB.QueryRow(` + err := h.Store.QueryRow(` SELECT m.merchant_id, m.settlement_bank_name, m.settlement_account_name, m.settlement_account_number FROM merchants m JOIN users u ON m.user_id = u.user_id @@ -131,17 +131,17 @@ func (h *Handler) WithdrawHandler(w http.ResponseWriter, r *http.Request) { return } - if req.Amount < 500 { + if req.Amount.LessThan(decimal.NewFromFloat(500)) { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ Success: false, - Message: "Minimum withdrawal amount is ₱500.00.", + Message: "Minimum withdrawal amount is ₱500.00.", }) return } // Check daily maximum withdrawal limit of 500,000 - var dailyWithdrawn float64 - err = h.DB.QueryRow(` + var dailyWithdrawn decimal.Decimal + err = h.Store.QueryRow(` SELECT COALESCE(SUM(amount), 0) FROM transactions WHERE merchant_id = ? AND transaction_type = 'withdrawal' AND DATE(created_at) = CURDATE() @@ -156,16 +156,17 @@ func (h *Handler) WithdrawHandler(w http.ResponseWriter, r *http.Request) { return } - if dailyWithdrawn+req.Amount > 500000 { + // if amount is greater than daily withdrawn amount it throw an error "Amount exceeds daily withdrawal limit" + if dailyWithdrawn.Add(req.Amount).GreaterThan(decimal.NewFromFloat(5000000)) { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ Success: false, - Message: fmt.Sprintf("Amount exceeds daily withdrawal limit of ₱500,000.00. You can only withdraw up to ₱%.2f more today.", 500000-dailyWithdrawn), + Message: fmt.Sprintf("Amount exceeds daily withdrawal limit of ₱500,000.00. You can only withdraw up to ₱%s more today.", (decimal.NewFromFloat(500000)).Sub(dailyWithdrawn)), }) return } - withdrawAmount := decimal.NewFromFloat(req.Amount) - if withdrawAmount.GreaterThan(stats.AvailableBalance) { + // if amount is greater than available balance it throw an error "Insufficient available balance" + if req.Amount.GreaterThan(stats.AvailableBalance) { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ Success: false, Message: fmt.Sprintf("Insufficient available balance. You can only withdraw up to %.2f", stats.AvailableBalance.InexactFloat64()), @@ -196,18 +197,18 @@ func (h *Handler) WithdrawHandler(w http.ResponseWriter, r *http.Request) { bankName := strings.TrimSpace(*bank.settlementBank) channelCode, exists := channelCodeMap[bankName] if !exists { - channelCode = "PH_" + strings.ReplaceAll(bankName, " ", "") + channelCode = "PH_" + strings.ReplaceAll(bankName, "", "") } // Calculate fees and final payout - serviceFee := float32(10.00) - payoutAmount := float32(req.Amount) - serviceFee + serviceFee := decimal.NewFromFloat(15.00) + payoutAmount := req.Amount.Sub(serviceFee).InexactFloat64() createPayoutReq := payout.NewCreatePayoutRequest( txnID, channelCode, *channelProps, - payoutAmount, + float32(payoutAmount), "PHP", ) createPayoutReq.SetDescription(description) @@ -235,7 +236,7 @@ func (h *Handler) WithdrawHandler(w http.ResponseWriter, r *http.Request) { transaction_id, merchant_id, transaction_type, amount, status, description, card_number, service_fee ) VALUES (?, ?, 'withdrawal', ?, 'pending', ?, NULL, ?) ` - _, err = h.DB.Exec(insertTxnQuery, txnID, bank.merchantID, req.Amount, description, serviceFee) + _, err = h.Store.Exec(insertTxnQuery, txnID, bank.merchantID, req.Amount, description, serviceFee) if err != nil { log.Println("Error inserting withdrawal transaction:", err) // We could potentially try to cancel the disbursement here, or have a manual reconciliation process. diff --git a/backend/internal/merchant/xendit_disbursement_webhook.go b/backend/internal/merchant/xendit_disbursement_webhook.go index 571baca..2e05347 100644 --- a/backend/internal/merchant/xendit_disbursement_webhook.go +++ b/backend/internal/merchant/xendit_disbursement_webhook.go @@ -6,17 +6,19 @@ import ( "log" "net/http" "os" + + "github.com/shopspring/decimal" ) // XenditPayoutWebhookPayload represents the expected payload from Xendit Payout webhook type XenditPayoutWebhookPayload struct { Event string `json:"event"` Data struct { - ReferenceID string `json:"reference_id"` - Status string `json:"status"` // SUCCEEDED, FAILED - ChannelCode string `json:"channel_code"` - Amount float64 `json:"amount"` - FailureCode string `json:"failure_code,omitempty"` + ReferenceID string `json:"reference_id"` + Status string `json:"status"` // SUCCEEDED, FAILED + ChannelCode string `json:"channel_code"` + Amount decimal.Decimal `json:"amount"` + FailureCode string `json:"failure_code,omitempty"` } `json:"data"` } @@ -51,16 +53,16 @@ func (h *Handler) XenditDisbursementWebhook(w http.ResponseWriter, r *http.Reque // Payout events: payout.succeeded, payout.failed switch payload.Event { case "payout.succeeded": - _, err := h.DB.Exec(`UPDATE transactions SET status = 'completed' WHERE transaction_id = ? AND transaction_type = 'withdrawal' AND status = 'pending'`, externalID) + _, err := h.Store.Exec(`UPDATE transactions SET status = 'completed' WHERE transaction_id = ? AND transaction_type = 'withdrawal' AND status = 'pending'`, externalID) if err != nil { log.Println("Failed to update withdrawal transaction to completed:", err) w.WriteHeader(http.StatusInternalServerError) return } - log.Printf("Successfully disbursed ₱%.2f for transaction %s", payload.Data.Amount, externalID) + log.Printf("Successfully disbursed ₱%s for transaction %s", payload.Data.Amount, externalID) case "payout.failed": - _, err := h.DB.Exec(`UPDATE transactions SET status = 'failed' WHERE transaction_id = ? AND transaction_type = 'withdrawal' AND status = 'pending'`, externalID) + _, err := h.Store.Exec(`UPDATE transactions SET status = 'failed' WHERE transaction_id = ? AND transaction_type = 'withdrawal' AND status = 'pending'`, externalID) if err != nil { log.Println("Failed to update withdrawal transaction to failed:", err) w.WriteHeader(http.StatusInternalServerError) diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 353365e..efebac5 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -78,7 +78,7 @@ func RequireAuth(allowedRoles ...string) func(http.Handler) http.Handler { Expires: time.Now().Add(15 * time.Minute), HttpOnly: true, Secure: true, - SameSite: http.SameSiteStrictMode, + SameSite: http.SameSiteLaxMode, Path: "/", }) http.SetCookie(w, &http.Cookie{ @@ -87,7 +87,7 @@ func RequireAuth(allowedRoles ...string) func(http.Handler) http.Handler { Expires: time.Now().Add(7 * 24 * time.Hour), HttpOnly: true, Secure: true, - SameSite: http.SameSiteStrictMode, + SameSite: http.SameSiteLaxMode, Path: "/", }) diff --git a/backend/internal/pkg/database/connection.go b/backend/internal/pkg/database/connection.go new file mode 100644 index 0000000..96b755f --- /dev/null +++ b/backend/internal/pkg/database/connection.go @@ -0,0 +1,35 @@ +package database + +import ( + "database/sql" + "fmt" + "log" + "os" + + _ "github.com/go-sql-driver/mysql" +) + +// Connect creates a database connection using environment variables +// and returns the sql.DB instance. +func Connect() (*sql.DB, error) { + dbUser := os.Getenv("DB_USER") + dbPass := os.Getenv("DB_PASSWORD") + dbName := os.Getenv("DB_NAME") + dbHost := os.Getenv("DB_HOST") + dbPort := os.Getenv("DB_PORT") + + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&loc=Local", dbUser, dbPass, dbHost, dbPort, dbName) + + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // Always verify connection + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("database connection failed: %w", err) + } + + log.Println("Database connection established") + return db, nil +} diff --git a/backend/internal/pkg/database/store.go b/backend/internal/pkg/database/store.go new file mode 100644 index 0000000..c99c46d --- /dev/null +++ b/backend/internal/pkg/database/store.go @@ -0,0 +1,88 @@ +package database + +import ( + "context" + "database/sql" +) + +// Store defines the interface for database operations and transactions. +type Store interface { + DB() *sql.DB + ExecTx(ctx context.Context, fn func(tx *sql.Tx) error) error + Begin() (*sql.Tx, error) + BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) + + // Add some standard querying shortcuts if needed + QueryRow(query string, args ...any) *sql.Row + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row + Query(query string, args ...any) (*sql.Rows, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + Exec(query string, args ...any) (sql.Result, error) + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +// SQLStore is the sql.DB implementation of Store. +type SQLStore struct { + db *sql.DB +} + +// NewStore creates a new Store instance. +func NewStore(db *sql.DB) Store { + return &SQLStore{db: db} +} + +// DB returns the underlying sql.DB instance. +func (s *SQLStore) DB() *sql.DB { + return s.db +} + +func (s *SQLStore) Begin() (*sql.Tx, error) { + return s.db.Begin() +} + +func (s *SQLStore) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) { + return s.db.BeginTx(ctx, opts) +} + +// ExecTx provides a clean way to execute a block of database operations within a transaction. +func (s *SQLStore) ExecTx(ctx context.Context, fn func(tx *sql.Tx) error) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + + err = fn(tx) + if err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + return err // Return original error, or combine them + } + return err + } + + return tx.Commit() +} + +// Standard query wrappers +func (s *SQLStore) QueryRow(query string, args ...any) *sql.Row { + return s.db.QueryRow(query, args...) +} + +func (s *SQLStore) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { + return s.db.QueryRowContext(ctx, query, args...) +} + +func (s *SQLStore) Query(query string, args ...any) (*sql.Rows, error) { + return s.db.Query(query, args...) +} + +func (s *SQLStore) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + return s.db.QueryContext(ctx, query, args...) +} + +func (s *SQLStore) Exec(query string, args ...any) (sql.Result, error) { + return s.db.Exec(query, args...) +} + +func (s *SQLStore) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + return s.db.ExecContext(ctx, query, args...) +} diff --git a/backend/internal/pkg/structs/struct.go b/backend/internal/pkg/structs/struct.go index 813920f..c7e4201 100644 --- a/backend/internal/pkg/structs/struct.go +++ b/backend/internal/pkg/structs/struct.go @@ -4,11 +4,11 @@ import "github.com/shopspring/decimal" // CardData struct represents the data required to create a new card type CardData struct { - CardUID string `json:"card_uid" db:"card_uid" validate:"required"` - CardNumber string `json:"cardNumber" db:"card_number"` - CardHolder string `json:"cardHolder" db:"user_id"` - CardType string `json:"cardType" db:"card_type"` - Balance float64 `json:"initial_amount" db:"balance" validate:"required,min=0"` + CardUID string `json:"card_uid" db:"card_uid" validate:"required"` + CardNumber string `json:"cardNumber" db:"card_number"` + CardHolder string `json:"cardHolder" db:"user_id"` + CardType string `json:"cardType" db:"card_type"` + Balance decimal.Decimal `json:"initial_amount" db:"balance" validate:"required,min=0"` } // List of all merchants @@ -27,25 +27,25 @@ type Merchant struct { // AdminDashboardData struct represents the data to be displayed on the admin dashboard type AdminDashboardData struct { - GrossRevenue float64 `json:"grossRevenue"` - NetRevenue float64 `json:"netRevenue"` - TotalUsers int `json:"totalUsers"` - TotalCards int `json:"totalCards"` - TotalMerchants int `json:"totalMerchants"` - PendingMerchants int `json:"pendingMerchants"` - SuspendedMerchants int `json:"suspendedMerchants"` - RejectedMerchants int `json:"rejectedMerchants"` - TotalTerminals int `json:"totalTerminals"` - ActiveTerminals int `json:"activeTerminals"` - InactiveTerminals int `json:"inactiveTerminals"` - Merchants []Merchant `json:"merchants"` + GrossRevenue decimal.Decimal `json:"grossRevenue"` + NetRevenue decimal.Decimal `json:"netRevenue"` + TotalUsers int `json:"totalUsers"` + TotalCards int `json:"totalCards"` + TotalMerchants int `json:"totalMerchants"` + PendingMerchants int `json:"pendingMerchants"` + SuspendedMerchants int `json:"suspendedMerchants"` + RejectedMerchants int `json:"rejectedMerchants"` + TotalTerminals int `json:"totalTerminals"` + ActiveTerminals int `json:"activeTerminals"` + InactiveTerminals int `json:"inactiveTerminals"` + Merchants []Merchant `json:"merchants"` } // Terminal represents a hardware device in the terminal registry type Terminal struct { - TerminalID string `json:"terminal_id"` - TerminalSN string `json:"terminal_sn"` - AssignedMerch string `json:"assigned_merchant"` + TerminalID string `json:"terminal_id"` + TerminalSN string `json:"terminal_sn"` + AssignedMerch string `json:"assigned_merchant"` DeviceName string `json:"device_name"` LocationDetails string `json:"location_details"` Status string `json:"status"` @@ -53,20 +53,20 @@ type Terminal struct { // Transaction struct represents a user's transaction for the dashboard view type Transaction struct { - Date string `db:"date" json:"date"` - Description string `db:"description" json:"description"` - Type string `db:"transaction_type" json:"type"` - Amount float64 `db:"transaction_amount" json:"amount"` + Date string `db:"date" json:"date"` + Description string `db:"description" json:"description"` + Type string `db:"transaction_type" json:"type"` + Amount decimal.Decimal `db:"transaction_amount" json:"amount"` } // DashboardUser info struct for the user dashboard view type DashboardUser struct { - ID int `db:"id" json:"id,omitempty"` - UserID string `db:"user_id" json:"user_id,omitempty"` - Username string `db:"username" json:"username"` - Name string `db:"name" json:"name"` + ID int `db:"id" json:"id,omitempty"` + UserID string `db:"user_id" json:"user_id,omitempty"` + Username string `db:"username" json:"username"` + Name string `db:"name" json:"name"` Balance float64 `db:"balance" json:"balance"` LoyaltyPoints decimal.Decimal `db:"loyalty_points" json:"loyalty_points"` AccountType string `db:"account_type" json:"account_type"` - RecentTransactions []Transaction `json:"transactions"` // Add recent transactions to the dashboard response -} \ No newline at end of file + RecentTransactions []Transaction `json:"transactions"` // Add recent transactions to the dashboard response +} diff --git a/backend/internal/user/customer_card.go b/backend/internal/user/customer_card.go index 3a35664..a86d178 100644 --- a/backend/internal/user/customer_card.go +++ b/backend/internal/user/customer_card.go @@ -31,7 +31,7 @@ func (h *Handler) UpdateCardStatus(w http.ResponseWriter, r *http.Request) { } var userID string - err := h.DB.QueryRow("SELECT user_id FROM users WHERE username = ?", username).Scan(&userID) + err := h.Store.QueryRow("SELECT user_id FROM users WHERE username = ?", username).Scan(&userID) if err != nil { http.Error(w, "User not found", http.StatusNotFound) return @@ -43,7 +43,7 @@ func (h *Handler) UpdateCardStatus(w http.ResponseWriter, r *http.Request) { return } - _, err = h.DB.Exec("UPDATE cards SET status = ? WHERE user_id = ?", req.Status, userID) + _, err = h.Store.Exec("UPDATE cards SET status = ? WHERE user_id = ?", req.Status, userID) if err != nil { http.Error(w, "Failed to update card status", http.StatusInternalServerError) return diff --git a/backend/internal/user/customer_profile.go b/backend/internal/user/customer_profile.go index 9859d27..b1b6a0c 100644 --- a/backend/internal/user/customer_profile.go +++ b/backend/internal/user/customer_profile.go @@ -42,7 +42,7 @@ type ProfileUpdateRequest struct { // (e.g. preventing the new password from being the same as the current one). func (h *Handler) verifyCurrentPassword(ctx context.Context, username, password string) (string, error) { var currentHash string - err := h.DB.QueryRowContext(ctx, "SELECT password_hash FROM users WHERE username = ?", username).Scan(¤tHash) + err := h.Store.QueryRowContext(ctx, "SELECT password_hash FROM users WHERE username = ?", username).Scan(¤tHash) if err != nil { return "", fmt.Errorf("%w: %v", ErrPasswordLookupFailed, err) } @@ -85,7 +85,7 @@ func (h *Handler) ProfileEdit(w http.ResponseWriter, r *http.Request) { return } - // PATCH — at least one field required + // PATCH — at least one field required if req.FullName == "" && req.Email == "" && req.Phone == "" && req.Username == "" { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ Success: false, @@ -101,7 +101,7 @@ func (h *Handler) ProfileEdit(w http.ResponseWriter, r *http.Request) { var currentEmail, currentName string emailChanged := false if req.Email != "" { - err := h.DB.QueryRowContext(ctx, "SELECT email, name FROM users WHERE username = ?", username).Scan(¤tEmail, ¤tName) + err := h.Store.QueryRowContext(ctx, "SELECT email, name FROM users WHERE username = ?", username).Scan(¤tEmail, ¤tName) if err != nil && err != sql.ErrNoRows { log.Printf("Failed to get current user data: %v", err) } else if req.Email != currentEmail { @@ -180,7 +180,7 @@ func (h *Handler) ProfileEdit(w http.ResponseWriter, r *http.Request) { query := "UPDATE users SET " + strings.Join(fields, ", ") + " WHERE username = ?" - _, err := h.DB.ExecContext(ctx, query, args...) + _, err := h.Store.ExecContext(ctx, query, args...) if err != nil { log.Printf("ProfileEdit DB error: %v | query: %s | args: %v", err, query, args) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ @@ -324,7 +324,7 @@ func (h *Handler) ProfileChangePassword(w http.ResponseWriter, r *http.Request) // Save new password hash query := "UPDATE users SET password_hash = ? WHERE username = ?" - _, err = h.DB.ExecContext(ctx, query, hashedPassword, username) + _, err = h.Store.ExecContext(ctx, query, hashedPassword, username) if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ Success: false, diff --git a/backend/internal/user/customer_topup.go b/backend/internal/user/customer_topup.go index ffea206..8d1bd35 100644 --- a/backend/internal/user/customer_topup.go +++ b/backend/internal/user/customer_topup.go @@ -1,6 +1,7 @@ package user import ( + "context" "database/sql" "encoding/json" "fmt" @@ -10,14 +11,15 @@ import ( "time" jsonwrite "unicard-go/backend/internal/pkg/handler" - "github.com/xendit/xendit-go" - "github.com/xendit/xendit-go/invoice" + "github.com/shopspring/decimal" + xendit "github.com/xendit/xendit-go/v7" + "github.com/xendit/xendit-go/v7/invoice" ) // struct for topup request only, for api call not for saving in db type TopUpRequest struct { - CardNumber string `json:"card_number"` - Amount float64 `json:"amount"` + CardNumber string `json:"card_number"` + Amount decimal.Decimal `json:"amount"` } // struct for topup record, for saving in db and for webhook callback processing @@ -25,12 +27,12 @@ type TopUpRequest struct { // the external_id is encoded in the external_id field of the xendit invoice // this is necessary because xendit v1 doesn't have a metadata field type TopUpRecord struct { - TopupID string `json:"topup_id" db:"topup_id"` - CardNumber string `json:"card_number" db:"card_number"` - Amount float64 `json:"amount" db:"amount"` - ConvenienceFee float64 `json:"convenience_fee" db:"convenience_fee"` - GatewayCost float64 `json:"gateway_cost" db:"gateway_cost"` - PaymentMethod string `json:"payment_method" db:"payment_method"` + TopupID string `json:"topup_id" db:"topup_id"` + CardNumber string `json:"card_number" db:"card_number"` + Amount decimal.Decimal `json:"amount" db:"amount"` + ConvenienceFee decimal.Decimal `json:"convenience_fee" db:"convenience_fee"` + GatewayCost decimal.Decimal `json:"gateway_cost" db:"gateway_cost"` + PaymentMethod string `json:"payment_method" db:"payment_method"` } // TopUpView displays the top-up page for a user @@ -65,7 +67,7 @@ func (h *Handler) CreateXenditInvoice(w http.ResponseWriter, r *http.Request) { } // check if amount is at least 50 pesos - if req.Amount < 50 { + if req.Amount.LessThan(decimal.NewFromFloat(50.0)) { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ Success: false, Message: "Minimum topup amount is 50 PHP", @@ -73,9 +75,14 @@ func (h *Handler) CreateXenditInvoice(w http.ResponseWriter, r *http.Request) { return } + // set fee amount and total amount + topupAmount := req.Amount + feeAmount := decimal.NewFromFloat(15.00) + totalAmount := topupAmount.Add(feeAmount).InexactFloat64() + // Fetch card number and email securely from DB instead of trusting the frontend var cardNumber, email string - err := h.DB.QueryRow(` + err := h.Store.QueryRow(` SELECT c.card_number, u.email FROM cards c JOIN users u ON c.user_id = u.user_id @@ -92,27 +99,18 @@ func (h *Handler) CreateXenditInvoice(w http.ResponseWriter, r *http.Request) { return } - // set fee amount and total amount - topupAmount := req.Amount - feeAmount := 15.00 - totalAmount := topupAmount + feeAmount - // set domain - domain := "http://" + os.Getenv("SERVER_PORT") + os.Getenv("PORT") + domain := "http://" + os.Getenv("SERVER_PORT") + ":" + os.Getenv("PORT") // Fallback if domain is malformed if domain == "http://" { domain = "http://127.0.0.1:3000" } - // set xendit secret key - xendit.Opt.SecretKey = os.Getenv("XENDIT_SECRET_KEY") - // Generate Unique IDs topupID := fmt.Sprintf("TOPUP-%d", time.Now().UnixNano()) - transactionID := fmt.Sprintf("TX-%d", time.Now().UnixNano()) // Start Database Transaction to insert PENDING records - tx, dbErr := h.DB.Begin() + tx, dbErr := h.Store.Begin() if dbErr != nil { log.Println("Failed to start transaction:", dbErr) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ @@ -134,18 +132,6 @@ func (h *Handler) CreateXenditInvoice(w http.ResponseWriter, r *http.Request) { return } - // Insert into Spending Ledger (transactions table) with status = 'pending' - queryTx := `INSERT INTO transactions (transaction_id, card_number, merchant_id, terminal_id, transaction_type, amount, service_fee, processed_by, description, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - if _, err := tx.Exec(queryTx, transactionID, cardNumber, "xendit", "xendit", "topup", topupAmount, feeAmount, "xendit", "Pending topup via Xendit", "pending"); err != nil { - tx.Rollback() - log.Println("Failed to record pending transaction:", err) - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "Failed to record pending transaction", - }) - return - } - // Commit the pending records if err := tx.Commit(); err != nil { log.Println("Failed to finalize pending records:", err) @@ -156,29 +142,44 @@ func (h *Handler) CreateXenditInvoice(w http.ResponseWriter, r *http.Request) { return } + // set xendit secret key + xenditClient := xendit.NewClient(os.Getenv("XENDIT_SECRET_KEY")) + // The Xendit ExternalID will cleanly map to our topup_id externalID := topupID // create xendit invoice struct with parameters - data := invoice.CreateParams{ - ExternalID: externalID, - Amount: totalAmount, - PayerEmail: email, - Description: fmt.Sprintf("Unicard Top-Up (Card: %s)", cardNumber), - SuccessRedirectURL: domain + "/u/" + username + "/dashboard", - FailureRedirectURL: domain + "/u/" + username + "/topup", - PaymentMethods: []string{"CREDIT_CARD", "GCASH", "PAYMAYA", "GRABPAY", - "SHOPEEPAY", "QRPH", "7ELEVEN", "CEBUANA", "ECPAY", - "BANK_TRANSFER"}, - Currency: "PHP", - } + + data := *invoice.NewCreateInvoiceRequest(externalID, totalAmount) + data.SetItems([]invoice.InvoiceItem{ + { + Name: "Unicard Top-Up", + Price: float32(topupAmount.InexactFloat64()), + Quantity: 1, + }, + }) + data.SetFees([]invoice.InvoiceFee{ + { + Type: "Convenience Fee", + Value: float32(feeAmount.InexactFloat64()), + }, + }) + data.SetPayerEmail(email) + data.SetDescription(fmt.Sprintf("Unicard Top-Up (Card: %s)", cardNumber)) + data.SetPaymentMethods([]string{"CREDIT_CARD", "UBP_DIRECT_DEBIT", "BPI_DIRECT_DEBIT", "GCASH", "PAYMAYA", "GRABPAY", + "SHOPEEPAY", "QRPH", "7ELEVEN"}) + data.SetCurrency("PHP") + data.SetInvoiceDuration(float32(1 * 60)) // 1 minutes invoice expiration + data.SetSuccessRedirectUrl(domain + "/u/" + username + "/dashboard") + data.SetFailureRedirectUrl(domain + "/u/" + username + "/topup") // creating the checkout session - resp, xErr := invoice.Create(&data) + resp, _, xenditErr := xenditClient.InvoiceApi.CreateInvoice(context.Background()). + CreateInvoiceRequest(data). + Execute() - // Handle error if session creation fails - if xErr != nil { - log.Println("Failed to create checkout session:", xErr) + if xenditErr != nil { + log.Println("Failed to create checkout session:", xenditErr) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ Success: false, Message: "Failed to create checkout session", @@ -191,11 +192,15 @@ func (h *Handler) CreateXenditInvoice(w http.ResponseWriter, r *http.Request) { jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ Success: true, Message: "Checkout session created successfully", - Data: map[string]string{"url": resp.InvoiceURL}, + Data: map[string]string{"url": resp.GetInvoiceUrl()}, }) + // log the response from xendit. Can be useful for debugging only + out, _ := json.MarshalIndent(resp, "", " ") + fmt.Fprintf(os.Stdout, "Response from `InvoiceApi.CreateInvoice`: %s\n", string(out)) + // log the checkout session url - log.Println("Checkout session created successfully:", resp.InvoiceURL) + log.Println("Checkout session created successfully:", resp.GetInvoiceUrl()) } // save topup tp database @@ -216,7 +221,7 @@ func (h *Handler) SaveTopUpToDatabase(w http.ResponseWriter, r *http.Request) { transactionID := fmt.Sprintf("TX-%d", time.Now().UnixNano()) // Start the Database Transaction - tx, err := h.DB.Begin() + tx, err := h.Store.Begin() if err != nil { log.Println("Failed to start transaction:", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ diff --git a/backend/internal/user/dashboard.go b/backend/internal/user/dashboard.go index e787d36..ea79804 100644 --- a/backend/internal/user/dashboard.go +++ b/backend/internal/user/dashboard.go @@ -105,7 +105,7 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) { ON u.user_id = c.user_id WHERE u.username = ? ` - err := h.DB.QueryRow(stmt, userID).Scan(&id, &username, &fullName, &email, &pendingEmail, &phone, &userType, &balance, &loyaltyPoints, &cardNumber, &expiryDate, &cardStatus) + err := h.Store.QueryRow(stmt, userID).Scan(&id, &username, &fullName, &email, &pendingEmail, &phone, &userType, &balance, &loyaltyPoints, &cardNumber, &expiryDate, &cardStatus) if err != nil { if err == sql.ErrNoRows { fmt.Printf("User %s not found in DB\n", userID) @@ -153,7 +153,7 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) { WHERE u.username = ? ORDER BY t.created_at DESC LIMIT 5 ` - rows, err := h.DB.Query(txnQuery, userID) + rows, err := h.Store.Query(txnQuery, userID) var transactions []Transaction if err != nil { fmt.Printf("Error fetching transactions: %v\n", err) diff --git a/backend/internal/user/handler.go b/backend/internal/user/handler.go index 4b42394..61181e9 100644 --- a/backend/internal/user/handler.go +++ b/backend/internal/user/handler.go @@ -1,16 +1,16 @@ package user import ( - "database/sql" "html/template" + "unicard-go/backend/internal/pkg/database" ) type Handler struct { - DB *sql.DB - Tpl *template.Template + Store database.Store + Tpl *template.Template } -func NewHandler(db *sql.DB, tpl *template.Template) *Handler { - return &Handler{DB: db, Tpl: tpl} +func NewHandler(store database.Store, tpl *template.Template) *Handler { + return &Handler{Store: store, Tpl: tpl} } diff --git a/backend/internal/user/transaction.go b/backend/internal/user/transaction.go index 3fd62cb..560eb85 100644 --- a/backend/internal/user/transaction.go +++ b/backend/internal/user/transaction.go @@ -56,7 +56,7 @@ func (h *Handler) TransactionsJSONHandler(w http.ResponseWriter, r *http.Request WHERE u.username = ? ORDER BY t.created_at DESC ` - rows, err := h.DB.Query(txnQuery, username) + rows, err := h.Store.Query(txnQuery, username) type TxnResponse struct { TransactionID string `json:"transaction_id"` diff --git a/backend/internal/user/verify_email.go b/backend/internal/user/verify_email.go index 183e78a..2c38d3b 100644 --- a/backend/internal/user/verify_email.go +++ b/backend/internal/user/verify_email.go @@ -18,7 +18,7 @@ func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) { var username, pendingEmail string // Find the user with this token - err := h.DB.QueryRowContext(ctx, "SELECT username, pending_email FROM users WHERE email_verification_token = ?", token).Scan(&username, &pendingEmail) + err := h.Store.QueryRowContext(ctx, "SELECT username, pending_email FROM users WHERE email_verification_token = ?", token).Scan(&username, &pendingEmail) if err != nil { if err == sql.ErrNoRows { http.Error(w, "Invalid or expired token", http.StatusBadRequest) @@ -35,7 +35,7 @@ func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) { } // Update the user's email and clear the pending_email and token - _, err = h.DB.ExecContext(ctx, "UPDATE users SET email = ?, pending_email = NULL, email_verification_token = NULL WHERE username = ?", pendingEmail, username) + _, err = h.Store.ExecContext(ctx, "UPDATE users SET email = ?, pending_email = NULL, email_verification_token = NULL WHERE username = ?", pendingEmail, username) if err != nil { log.Printf("VerifyEmail update error: %v", err) http.Error(w, "Failed to verify email", http.StatusInternalServerError) diff --git a/backend/internal/user/xendit_webhook.go b/backend/internal/user/xendit_webhook.go index 7d0ffbc..232d4aa 100644 --- a/backend/internal/user/xendit_webhook.go +++ b/backend/internal/user/xendit_webhook.go @@ -1,35 +1,41 @@ package user import ( + "database/sql" "encoding/json" + "fmt" "io" "log" "net/http" "os" + "strings" + "time" + + "github.com/shopspring/decimal" ) // XenditWebhookPayload represents the expected payload from Xendit Invoice webhook type XenditWebhookPayload struct { - ID string `json:"id"` - ExternalID string `json:"external_id"` - UserID string `json:"user_id"` - IsHigh bool `json:"is_high"` - PaymentMethod string `json:"payment_method"` - Status string `json:"status"` - MerchantName string `json:"merchant_name"` - Amount float64 `json:"amount"` - PaidAmount float64 `json:"paid_amount"` - BankCode string `json:"bank_code"` - PaidAt string `json:"paid_at"` - PayerEmail string `json:"payer_email"` - Description string `json:"description"` - AdjustedReceivedAmount float64 `json:"adjusted_received_amount"` - FeesPaidAmount float64 `json:"fees_paid_amount"` - Updated string `json:"updated"` - Created string `json:"created"` - Currency string `json:"currency"` - PaymentChannel string `json:"payment_channel"` - PaymentDestination string `json:"payment_destination"` + ID string `json:"id"` + ExternalID string `json:"external_id"` + UserID string `json:"user_id"` + IsHigh bool `json:"is_high"` + PaymentMethod string `json:"payment_method"` + Status string `json:"status"` + MerchantName string `json:"merchant_name"` + Amount decimal.Decimal `json:"amount"` + PaidAmount decimal.Decimal `json:"paid_amount"` + BankCode string `json:"bank_code"` + PaidAt string `json:"paid_at"` + PayerEmail string `json:"payer_email"` + Description string `json:"description"` + AdjustedReceivedAmount decimal.Decimal `json:"adjusted_received_amount"` + FeesPaidAmount decimal.Decimal `json:"fees_paid_amount"` + Updated string `json:"updated"` + Created string `json:"created"` + Currency string `json:"currency"` + PaymentChannel string `json:"payment_channel"` + PaymentDestination string `json:"payment_destination"` } // XenditWebhook handles incoming webhook notifications from Xendit for invoice payments. @@ -69,74 +75,95 @@ func (h *Handler) XenditWebhook(w http.ResponseWriter, r *http.Request) { externalID := payload.ExternalID // This maps to our topup_id // Start Database Transaction - tx, err := h.DB.Begin() - if err != nil { - log.Println("Failed to start transaction:", err) - w.WriteHeader(http.StatusInternalServerError) - return - } - defer tx.Rollback() + err = h.Store.ExecTx(r.Context(), func(tx *sql.Tx) error { + var topUp XenditWebhookPayload + var cardNumber string + var convenienceFee float64 + var currentStatus string + + // Fetch the top-up record + err := tx.QueryRow(`SELECT card_number, amount, convenience_fee, status FROM top_ups WHERE topup_id = ?`, externalID).Scan(&cardNumber, &topUp.Amount, &convenienceFee, ¤tStatus) + if err != nil { + log.Println("Failed to find top-up record or invalid external_id:", err) + return nil // Ignore if not found, don't rollback, just return success to not re-trigger webhook + } - var cardNumber string - var amount float64 - var currentStatus string + // Prevent double processing + if currentStatus == "completed" { + log.Println("Top-up already completed, skipping.") + return nil // Ignore if already processed + } - // Fetch the top-up record - err = tx.QueryRow(`SELECT card_number, amount, status FROM top_ups WHERE topup_id = ?`, externalID).Scan(&cardNumber, &amount, ¤tStatus) - if err != nil { - log.Println("Failed to find top-up record or invalid external_id:", err) - w.WriteHeader(http.StatusOK) // Ignore if not found - return - } + // Update the User's Balance + if _, err := tx.Exec(`UPDATE cards SET balance = balance + ? WHERE card_number = ?`, topUp.Amount, cardNumber); err != nil { + log.Println("Failed to update card balance:", err) + return fmt.Errorf("failed to update card balance") + } - // Prevent double processing - if currentStatus == "completed" { - log.Println("Top-up already completed, skipping.") - w.WriteHeader(http.StatusOK) - return - } + // Mark the top-up ledger as completed + if _, err := tx.Exec(`UPDATE top_ups SET status = 'completed' WHERE topup_id = ?`, externalID); err != nil { + log.Println("Failed to update top_ups status:", err) + return fmt.Errorf("failed to update top_ups status") + } - // Update the User's Balance - if _, err := tx.Exec(`UPDATE cards SET balance = balance + ? WHERE card_number = ?`, amount, cardNumber); err != nil { - log.Println("Failed to update card balance:", err) - w.WriteHeader(http.StatusInternalServerError) - return - } + // Upsert the transaction: update if a pending one exists, otherwise insert + res, err := tx.Exec(`UPDATE transactions SET status = 'completed', description = 'Successful topup via Xendit' WHERE card_number = ? AND transaction_type = 'topup' AND status = 'pending' AND amount = ? ORDER BY created_at DESC LIMIT 1`, cardNumber, topUp.Amount) + if err != nil { + log.Println("Failed to update transactions status:", err) + return fmt.Errorf("failed to update transactions status") + } - // Mark the top-up ledger as completed - if _, err := tx.Exec(`UPDATE top_ups SET status = 'completed' WHERE topup_id = ?`, externalID); err != nil { - log.Println("Failed to update top_ups status:", err) - w.WriteHeader(http.StatusInternalServerError) - return - } + rowsAffected, _ := res.RowsAffected() + if rowsAffected == 0 { + transactionID := fmt.Sprintf("TX-%d", time.Now().UnixNano()) + queryTx := `INSERT INTO transactions (transaction_id, card_number, merchant_id, terminal_id, transaction_type, amount, service_fee, processed_by, description, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + if _, err := tx.Exec(queryTx, transactionID, cardNumber, "xendit", "xendit", "topup", topUp.Amount, convenienceFee, "xendit", "Successful topup via Xendit", "completed"); err != nil { + log.Println("Failed to insert transaction:", err) + return fmt.Errorf("failed to insert transaction") + } + } - // Mark the transaction ledger as completed. We find the related pending transaction by card_number, amount, and status. - // We use LIMIT 1 to ensure we only update one pending transaction if there are duplicates. - if _, err := tx.Exec(`UPDATE transactions SET status = 'completed', description = 'Successful topup via Xendit' WHERE card_number = ? AND transaction_type = 'topup' AND status = 'pending' AND amount = ? ORDER BY created_at DESC LIMIT 1`, cardNumber, amount); err != nil { - log.Println("Failed to update transactions status:", err) - w.WriteHeader(http.StatusInternalServerError) - return - } + log.Printf("Successfully loaded ₱%s onto card %s via Xendit", topUp.Amount, cardNumber) + return nil + }) - if err := tx.Commit(); err != nil { - log.Println("Failed to commit transaction:", err) + if err != nil { + log.Println("Transaction failed:", err) w.WriteHeader(http.StatusInternalServerError) return } - log.Printf("Successfully loaded ₱%.2f onto card %s via Xendit", amount, cardNumber) - // log if payment failed, expired, or canceled - case "EXPIRED", "FAILED", "PENDING", "CANCELED": - log.Printf("Payment failed or pending for external ID: %s, status: %s", payload.ExternalID, payload.Status) - if payload.Status == "EXPIRED" || payload.Status == "FAILED" || payload.Status == "CANCELED" { - // Update the database records to failed so users see it as failed - _, _ = h.DB.Exec(`UPDATE top_ups SET status = 'failed' WHERE topup_id = ? AND status = 'pending'`, payload.ExternalID) + case "EXPIRED", "FAILED", "PENDING", "CANCELLED": + log.Printf("Payment Status for external ID: %s, status: %s", payload.ExternalID, payload.Status) + // Update the database records to failed so users see it as failed + _, _ = h.Store.Exec(`UPDATE top_ups SET status = ? WHERE topup_id = ?`, payload.Status, payload.ExternalID) + + var description string + switch payload.Status { + case "EXPIRED": + description = "topup expired" + case "FAILED": + description = "topup failed" + case "PENDING": + description = "topup pending" + case "CANCELLED": + description = "topup cancelled" + } + + var cardNumber string + var amount float64 + var convenienceFee float64 + if err := h.Store.QueryRow(`SELECT card_number, amount, convenience_fee FROM top_ups WHERE topup_id = ?`, payload.ExternalID).Scan(&cardNumber, &amount, &convenienceFee); err == nil { + res, err := h.Store.Exec(`UPDATE transactions SET status = ?, description = ? WHERE card_number = ? AND transaction_type = 'topup' AND status = 'pending' AND amount = ? ORDER BY created_at DESC LIMIT 1`, strings.ToLower(payload.Status), description, cardNumber, amount) - var cardNumber string - var amount float64 - if err := h.DB.QueryRow(`SELECT card_number, amount FROM top_ups WHERE topup_id = ?`, payload.ExternalID).Scan(&cardNumber, &amount); err == nil { - _, _ = h.DB.Exec(`UPDATE transactions SET status = 'failed', description = 'Failed topup via Xendit' WHERE card_number = ? AND transaction_type = 'topup' AND status = 'pending' AND amount = ? ORDER BY created_at DESC LIMIT 1`, cardNumber, amount) + if err == nil { + rowsAffected, _ := res.RowsAffected() + if rowsAffected == 0 { + transactionID := fmt.Sprintf("TX-%d", time.Now().UnixNano()) + queryTx := `INSERT INTO transactions (transaction_id, card_number, merchant_id, terminal_id, transaction_type, amount, service_fee, processed_by, description, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + _, _ = h.Store.Exec(queryTx, transactionID, cardNumber, "xendit", "xendit", "topup", amount, convenienceFee, "xendit", description, strings.ToLower(payload.Status)) + } } } } diff --git a/go.mod b/go.mod index a545687..f5a98b0 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/joho/godotenv v1.5.1 github.com/shopspring/decimal v1.4.0 - github.com/xendit/xendit-go v1.0.25 github.com/xendit/xendit-go/v7 v7.0.0 golang.org/x/crypto v0.53.0 golang.org/x/time v0.15.0 diff --git a/go.sum b/go.sum index ab1260f..21a5a6f 100644 --- a/go.sum +++ b/go.sum @@ -1,67 +1,44 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/xendit/xendit-go v1.0.25 h1:o93nh+imxUwEgezPXzz9A1pMEXBHcx7V9rUICZJXmNY= -github.com/xendit/xendit-go v1.0.25/go.mod h1:JPte2sEsATw1iUHkBiZpcRuySn0CmcomaeHjfDlwpYo= github.com/xendit/xendit-go/v7 v7.0.0 h1:A7Nhaulk1a+mOI/KgRcvb5VSQEB6nhsUGkAhi+RkrEM= github.com/xendit/xendit-go/v7 v7.0.0/go.mod h1:W562aw0zhjzF/OUhZLc77q2iFQc9INa5tBy5xl6OLbo= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=