From 90492b215d9dcf6ce47f88e76db6f9b6ccdeab40 Mon Sep 17 00:00:00 2001 From: devzeeh <148837352+devzeeh@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:02:52 +0800 Subject: [PATCH] feat: implement transaction dashboard with search, filtering, and pagination support --- backend/internal/admin/terminal_sim.go | 11 +- backend/internal/pkg/structs/struct.go | 8 +- backend/internal/user/dashboard.go | 76 +++++----- backend/internal/user/transaction.go | 188 +++++++++++-------------- frontend/assets/js/transaction.js | 18 ++- go.mod | 1 + go.sum | 2 + 7 files changed, 144 insertions(+), 160 deletions(-) diff --git a/backend/internal/admin/terminal_sim.go b/backend/internal/admin/terminal_sim.go index 14736ff..7511a62 100644 --- a/backend/internal/admin/terminal_sim.go +++ b/backend/internal/admin/terminal_sim.go @@ -7,6 +7,7 @@ import ( "net/http" "time" + "github.com/shopspring/decimal" jsonwrite "unicard-go/backend/internal/pkg/handler" ) @@ -127,7 +128,9 @@ func (h *Handler) TerminalSimTransactionHandler(w http.ResponseWriter, r *http.R } serviceFee := req.Amount * (commissionRate / 100.0) - loyaltyPoints := req.Amount * 0.002 // 0.2% reward points + + amountDec := decimal.NewFromFloat(req.Amount) + loyaltyPoints := amountDec.Mul(decimal.NewFromFloat(0.002)) // 3. Process Transaction (Start TX) tx, err := h.DB.Begin() @@ -178,9 +181,9 @@ func (h *Handler) TerminalSimTransactionHandler(w http.ResponseWriter, r *http.R } _, err = tx.Exec(` - INSERT INTO transactions (transaction_id, card_number, merchant_id, terminal_id, transaction_type, amount, service_fee, processed_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, transactionID, req.CardNumber, req.MerchantID, terminalID, dbTransactionType, req.Amount, serviceFee, processedBy) + INSERT INTO transactions (transaction_id, card_number, merchant_id, terminal_id, transaction_type, amount, service_fee, processed_by, points_earned) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, transactionID, req.CardNumber, req.MerchantID, terminalID, dbTransactionType, req.Amount, serviceFee, processedBy, loyaltyPoints) if err != nil { // If `merchant_id` is not nullable and causes error or id doesn't auto-increment diff --git a/backend/internal/pkg/structs/struct.go b/backend/internal/pkg/structs/struct.go index 39c3d7a..813920f 100644 --- a/backend/internal/pkg/structs/struct.go +++ b/backend/internal/pkg/structs/struct.go @@ -1,5 +1,7 @@ package structure +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"` @@ -63,8 +65,8 @@ type DashboardUser struct { 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 int `db:"loyalty_points" json:"loyalty_points"` - AccountType string `db:"account_type" json:"account_type"` + 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 diff --git a/backend/internal/user/dashboard.go b/backend/internal/user/dashboard.go index d817f30..e787d36 100644 --- a/backend/internal/user/dashboard.go +++ b/backend/internal/user/dashboard.go @@ -6,41 +6,43 @@ import ( "net/http" "strings" "time" + + "github.com/shopspring/decimal" jsonwrite "unicard-go/backend/internal/pkg/handler" ) type Transaction struct { - TransactionID string `json:"transaction_id"` - TerminalID string `json:"terminal_id"` - Date string `json:"date" db:"date"` - Time string `json:"time"` - Description string `json:"description" db:"description"` - Type string `json:"type" db:"transaction_type"` - Amount float64 `json:"amount" db:"transaction_amount"` - Status string `json:"status" db:"status"` - MerchantName string `json:"merchant_name"` - MerchantID string `json:"merchant_id"` - ServiceFee float64 `json:"service_fee"` - PointsEarned int `json:"points_earned"` + TransactionID string `json:"transaction_id"` + TerminalID string `json:"terminal_id"` + Date string `json:"date" db:"date"` + Time string `json:"time"` + Description string `json:"description" db:"description"` + Type string `json:"type" db:"transaction_type"` + Amount float64 `json:"amount" db:"transaction_amount"` + Status string `json:"status" db:"status"` + MerchantName string `json:"merchant_name"` + MerchantID string `json:"merchant_id"` + ServiceFee float64 `json:"service_fee"` + PointsEarned decimal.Decimal `json:"points_earned"` } // DashboardUser info struct for the user dashboard view type DashboardUser struct { - ID int `json:"id,omitempty" db:"id"` - UserID string `json:"user_id,omitempty" db:"user_id"` - Username string `json:"username" db:"username"` - Name string `json:"name" db:"name"` - Email string `json:"email" db:"email"` - PendingEmail string `json:"pending_email"` - Phone string `json:"phone" db:"phone"` - Initials string `json:"initials"` - Balance float64 `json:"balance" db:"balance"` - LoyaltyPoints float64 `json:"loyalty_points" db:"loyalty_points"` - AccountType string `db:"account_type" json:"account_type"` - CardNumber string `json:"card_number"` - CardExpiry string `json:"card_expiry"` - CardStatus string `json:"card_status"` - RecentTransactions []Transaction `json:"recent_transactions"` // Add recent transactions to the dashboard response + ID int `json:"id,omitempty" db:"id"` + UserID string `json:"user_id,omitempty" db:"user_id"` + Username string `json:"username" db:"username"` + Name string `json:"name" db:"name"` + Email string `json:"email" db:"email"` + PendingEmail string `json:"pending_email"` + Phone string `json:"phone" db:"phone"` + Initials string `json:"initials"` + Balance float64 `json:"balance" db:"balance"` + LoyaltyPoints decimal.Decimal `json:"loyalty_points" db:"loyalty_points"` + AccountType string `db:"account_type" json:"account_type"` + CardNumber string `json:"card_number"` + CardExpiry string `json:"card_expiry"` + CardStatus string `json:"card_status"` + RecentTransactions []Transaction `json:"recent_transactions"` // Add recent transactions to the dashboard response } func (h *Handler) DashboardView(w http.ResponseWriter, r *http.Request) { @@ -79,7 +81,7 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) { phone string userType string balance float64 - loyaltyPoints float64 + loyaltyPoints decimal.Decimal cardNumber string expiryDate string cardStatus string @@ -143,7 +145,7 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) { // Fetch recent transactions txnQuery := ` - SELECT t.transaction_id, t.description, t.created_at, t.transaction_type, t.amount, t.terminal_id, t.status, m.business_name, m.merchant_id + SELECT t.transaction_id, t.description, t.created_at, COALESCE(t.transaction_type, ''), t.amount, COALESCE(t.terminal_id, ''), COALESCE(t.status, ''), m.business_name, m.merchant_id, COALESCE(t.points_earned, 0) FROM transactions t JOIN cards c ON t.card_number = c.card_number JOIN users u ON c.user_id = u.user_id @@ -163,6 +165,7 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) { var description sql.NullString var businessName sql.NullString var merchantId sql.NullString + var pointsEarned decimal.Decimal if err := rows.Scan( &t.TransactionID, &description, @@ -173,6 +176,7 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) { &t.Status, &businessName, &merchantId, + &pointsEarned, ); err != nil { fmt.Printf("Error scanning transaction row: %v\n", err) continue @@ -181,14 +185,14 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) { t.Time = formatTime(createdAt) if description.Valid { t.Description = description.String + } else { + t.Description = "" } - if businessName.Valid { + if businessName.Valid && businessName.String != "" { t.MerchantName = businessName.String - } else if description.Valid { - t.MerchantName = description.String } else { - t.MerchantName = "System" + t.MerchantName = t.Description } if merchantId.Valid { @@ -198,11 +202,7 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) { } t.ServiceFee = 0.00 - if strings.ToLower(t.Type) == "payment" && t.Amount >= 100 { - t.PointsEarned = int(t.Amount / 100) - } else { - t.PointsEarned = 0 - } + t.PointsEarned = pointsEarned transactions = append(transactions, t) } diff --git a/backend/internal/user/transaction.go b/backend/internal/user/transaction.go index e0cce5f..3fd62cb 100644 --- a/backend/internal/user/transaction.go +++ b/backend/internal/user/transaction.go @@ -1,11 +1,13 @@ package user import ( - "database/sql" "fmt" "net/http" "strings" + jsonwrite "unicard-go/backend/internal/pkg/handler" + + "github.com/shopspring/decimal" ) // TransactionView renders the transaction.html template @@ -24,43 +26,53 @@ func (h *Handler) TransactionView(w http.ResponseWriter, r *http.Request) { // TransactionsJSONHandler returns the user's transactions as JSON func (h *Handler) TransactionsJSONHandler(w http.ResponseWriter, r *http.Request) { - fmt.Println("TransactionsJSONHandler is running...") - username := r.PathValue("username") if username == "" { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ Success: false, - Message: "user is required", + Message: "Username is required", }) return } txnQuery := ` - SELECT t.transaction_id, t.created_at, m.business_name, t.transaction_type, t.amount, t.terminal_id, t.description, t.status, c.card_number, m.merchant_id - FROM transactions t - JOIN cards c ON t.card_number = c.card_number - JOIN users u ON c.user_id = u.user_id - LEFT JOIN merchants m ON t.merchant_id = m.merchant_id - WHERE u.username = ? - ORDER BY t.created_at DESC - ` + SELECT + t.transaction_id, + COALESCE(t.terminal_id, ''), + DATE(t.created_at) as date, + TIME(t.created_at) as time, + COALESCE(t.transaction_type, ''), + t.amount, + COALESCE(t.status, ''), + COALESCE(t.description, ''), + COALESCE(m.business_name, ''), + COALESCE(m.merchant_id, ''), + COALESCE(t.points_earned, 0), + c.card_number + FROM transactions t + JOIN cards c ON t.card_number = c.card_number + JOIN users u ON c.user_id = u.user_id + LEFT JOIN merchants m ON t.merchant_id = m.merchant_id + WHERE u.username = ? + ORDER BY t.created_at DESC + ` rows, err := h.DB.Query(txnQuery, username) type TxnResponse struct { - TransactionID string `json:"transaction_id"` - TerminalID string `json:"terminal_id"` - Date string `json:"date"` - Time string `json:"time"` - Description string `json:"description"` - Type string `json:"type"` - Amount float64 `json:"amount"` - Status string `json:"status"` - MerchantName string `json:"merchant_name"` - MerchantID string `json:"merchant_id"` - ServiceFee float64 `json:"service_fee"` - PointsEarned int `json:"points_earned"` - Sender string `json:"sender"` - Receiver string `json:"receiver"` + TransactionID string `json:"transaction_id"` + TerminalID string `json:"terminal_id"` + Date string `json:"date"` + Time string `json:"time"` + Description string `json:"description"` + Type string `json:"type"` + Amount float64 `json:"amount"` + Status string `json:"status"` + MerchantName string `json:"merchant_name"` + MerchantID string `json:"merchant_id"` + ServiceFee float64 `json:"service_fee"` + PointsEarned decimal.Decimal `json:"points_earned"` + Sender string `json:"sender"` + Receiver string `json:"receiver"` } var transactions []TxnResponse @@ -68,89 +80,55 @@ func (h *Handler) TransactionsJSONHandler(w http.ResponseWriter, r *http.Request defer rows.Close() for rows.Next() { var t TxnResponse - var createdAt string - var businessName sql.NullString - var terminalId sql.NullString - var dbDescription sql.NullString - var dbStatus sql.NullString + var description string + var businessName string + var merchantId string + var pointsEarned decimal.Decimal var cardNumber string - var merchantId sql.NullString - if err := rows.Scan(&t.TransactionID, &createdAt, &businessName, &t.Type, &t.Amount, &terminalId, &dbDescription, &dbStatus, &cardNumber, &merchantId); err == nil { - - if dbStatus.Valid && dbStatus.String != "" { - t.Status = dbStatus.String - } else { - t.Status = "Completed" - } - - t.Date = formatDate(createdAt) // Uses formatDate from dashboard.go - t.Time = formatTime(createdAt) // Uses formatTime from dashboard.go - - if terminalId.Valid { - t.TerminalID = terminalId.String - } else { - t.TerminalID = "N/A" - } - - if dbDescription.Valid && dbDescription.String != "" { - t.Description = dbDescription.String - } else if businessName.Valid { - t.Description = businessName.String - } else if t.Type == "topup" { - t.Description = "Stripe Top-Up" - } else { - t.Description = "Transaction" - } - - if businessName.Valid { - t.MerchantName = businessName.String - } else { - t.MerchantName = t.Description - } - - if merchantId.Valid { - t.MerchantID = merchantId.String - } else { - t.MerchantID = "N/A" - } - - t.ServiceFee = 0.00 - if strings.ToLower(t.Type) == "payment" && t.Amount >= 100 { - t.PointsEarned = int(t.Amount / 100) - } else { - t.PointsEarned = 0 - } - - // Calculate Sender, Receiver, ServiceFee, PointsEarned - isPayment := strings.ToLower(t.Type) == "payment" - merchantStr := t.Description - if t.TerminalID != "" && t.TerminalID != "N/A" { - merchantStr += " (Terminal: " + t.TerminalID + ")" - } - cardStr := "My UniCard" - if len(cardNumber) >= 4 { - cardStr += " (**** " + cardNumber[len(cardNumber)-4:] + ")" - } - - if isPayment { - t.Sender = cardStr - t.Receiver = merchantStr - if t.Amount >= 100 { - t.PointsEarned = int(t.Amount / 100) - } - } else { - t.Sender = merchantStr - t.Receiver = cardStr - } - t.ServiceFee = 0.00 - - transactions = append(transactions, t) + + err := rows.Scan(&t.TransactionID, &t.TerminalID, &t.Date, &t.Time, &t.Type, &t.Amount, &t.Status, &description, &businessName, &merchantId, &pointsEarned, &cardNumber) + if err != nil { + fmt.Printf("Error scanning transaction row: %v\n", err) + continue + } + t.Description = description + + if businessName != "" { + t.MerchantName = businessName + } else { + t.MerchantName = "Transaction" + } + + if merchantId != "" { + t.MerchantID = merchantId } else { - fmt.Printf("Scan error: %v\n", err) + t.MerchantID = "N/A" } + + t.ServiceFee = 0.00 + t.PointsEarned = pointsEarned + + // Calculate Sender, Receiver + isPayment := strings.ToLower(t.Type) == "payment" + merchantStr := t.Description + if t.TerminalID != "" && t.TerminalID != "N/A" { + merchantStr += " (Terminal: " + t.TerminalID + ")" + } + cardStr := "My UniCard" + if len(cardNumber) >= 4 { + cardStr += " (**** " + cardNumber[len(cardNumber)-4:] + ")" + } + + if isPayment { + t.Sender = cardStr + t.Receiver = merchantStr + } else { + t.Sender = merchantStr + t.Receiver = cardStr + } + + transactions = append(transactions, t) } - } else { - fmt.Printf("Error fetching transactions: %v\n", err) } response := struct { diff --git a/frontend/assets/js/transaction.js b/frontend/assets/js/transaction.js index 819463e..50163a4 100644 --- a/frontend/assets/js/transaction.js +++ b/frontend/assets/js/transaction.js @@ -134,13 +134,13 @@ document.addEventListener("DOMContentLoaded", function () { txTime = tx.time || ""; } } - const status = tx.status || "Completed"; - - let statusColor = "bg-green-100 text-green-800"; - if (status.toLowerCase() === "pending") { - statusColor = "bg-yellow-100 text-yellow-800"; - } else if (status.toLowerCase() === "failed") { - statusColor = "bg-red-100 text-red-800"; + let statusHtml = ""; + if (tx.status) { + const statusVal = tx.status.toLowerCase(); + const statusColor = statusVal === "completed" ? "bg-green-100 text-green-800" : + statusVal === "pending" ? "bg-yellow-100 text-yellow-800" : + "bg-red-100 text-red-800"; + statusHtml = `${tx.status}`; } tr.className = "hover:bg-slate-50 transition-colors cursor-pointer border-b border-slate-100"; @@ -164,9 +164,7 @@ document.addEventListener("DOMContentLoaded", function () { ${sign}₱${amount} - - ${status} - + ${statusHtml} `; transactionsBody.appendChild(tr); diff --git a/go.mod b/go.mod index cf0665c..fbd4574 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/go-playground/validator/v10 v10.30.2 github.com/go-sql-driver/mysql v1.9.3 github.com/joho/godotenv v1.5.1 + github.com/shopspring/decimal v1.4.0 github.com/xendit/xendit-go v1.0.25 golang.org/x/crypto v0.49.0 golang.org/x/time v0.15.0 diff --git a/go.sum b/go.sum index 9ebca94..8fc95dc 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjS 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=