diff --git a/.gitignore b/.gitignore index afe833e..70b6e0f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,11 +2,15 @@ .env .unknown -# gitgore the compiled file +# gitgore the compiled file and *.exe +*.txt # gitgore the storage directory storage/* !storage/.gitkeep storage/**/* -!storage/**/.gitkeep \ No newline at end of file +!storage/**/.gitkeep + +tested/* +tested/**/* \ No newline at end of file diff --git a/backend/cmd/app/main.go b/backend/cmd/app/main.go index a52703e..b50c01a 100644 --- a/backend/cmd/app/main.go +++ b/backend/cmd/app/main.go @@ -10,14 +10,17 @@ import ( "unicard-go/backend/internal/admin" authentication "unicard-go/backend/internal/auth" "unicard-go/backend/internal/merchant" + "unicard-go/backend/internal/middleware" "unicard-go/backend/internal/user" _ "github.com/go-sql-driver/mysql" "github.com/joho/godotenv" ) -var tpl *template.Template -var db *sql.DB +var ( + tpl *template.Template + db *sql.DB +) func main() { // Load .env file @@ -76,7 +79,8 @@ func main() { // general endpoints mux.HandleFunc("GET /login", authHandler.LoginView) - mux.HandleFunc("POST /v1/loginauth", authHandler.LoginAuthHandler) // Login authentication endpoint + mux.HandleFunc("POST /v1/loginauth", authHandler.LoginAuthHandler) // Login authentication endpoint + mux.HandleFunc("POST /v1/refresh", authHandler.RefreshTokenHandler) // Refresh token endpoint mux.HandleFunc("GET /merchant-signup", authHandler.MerchantSignupView) mux.HandleFunc("POST /v1/merchant-signup", authHandler.MerchantSignupHandler) mux.HandleFunc("GET /admin-signup", authHandler.AdminSignupView) @@ -91,73 +95,81 @@ func main() { mux.HandleFunc("POST /v1/forgot-password/send-otp", authHandler.ForgotPasswordSendOTP) mux.HandleFunc("POST /v1/forgot-password/verify-otp", authHandler.ForgotPasswordVerifyOTP) mux.HandleFunc("POST /v1/reset-password", authHandler.ResetPassword) - mux.HandleFunc("GET /u/{username}", userHandler.ProfileView) - mux.HandleFunc("PATCH /u/{username}/profile/edit", userHandler.ProfileEdit) - mux.HandleFunc("GET /v1/verify-email", userHandler.VerifyEmail) - mux.HandleFunc("POST /v1/user/{username}/profile/verify-password", userHandler.ProfileVerifyPassword) - mux.HandleFunc("PUT /u/{username}/profile/password", userHandler.ProfileChangePassword) - mux.HandleFunc("GET /u/{username}/dashboard", userHandler.DashboardView) - mux.HandleFunc("GET /u/{username}/card", userHandler.CardView) - mux.HandleFunc("POST /v1/user/{username}/card/status", userHandler.UpdateCardStatus) - mux.HandleFunc("GET /u/{username}/settings", userHandler.SettingsView) - mux.HandleFunc("GET /u/{username}/topup", userHandler.TopUpView) + // Middleware definitions + requireCustomer := middleware.RequireAuth("customer") + requireMerchant := middleware.RequireAuth("merchant_admin", "merchant_staff") + requireAdmin := middleware.RequireAuth("super_admin") + + // Customer Routes + mux.Handle("GET /u/{username}", requireCustomer(http.HandlerFunc(userHandler.ProfileView))) + mux.Handle("PATCH /u/{username}/profile/edit", requireCustomer(http.HandlerFunc(userHandler.ProfileEdit))) + mux.Handle("POST /v1/user/{username}/profile/verify-password", requireCustomer(http.HandlerFunc(userHandler.ProfileVerifyPassword))) + mux.Handle("PUT /u/{username}/profile/password", requireCustomer(http.HandlerFunc(userHandler.ProfileChangePassword))) + mux.Handle("GET /u/{username}/dashboard", requireCustomer(http.HandlerFunc(userHandler.DashboardView))) + mux.Handle("GET /u/{username}/card", requireCustomer(http.HandlerFunc(userHandler.CardView))) + mux.Handle("POST /v1/user/{username}/card/status", requireCustomer(http.HandlerFunc(userHandler.UpdateCardStatus))) + mux.Handle("GET /u/{username}/settings", requireCustomer(http.HandlerFunc(userHandler.SettingsView))) + mux.Handle("GET /u/{username}/topup", requireCustomer(http.HandlerFunc(userHandler.TopUpView))) // Your frontend calls this to get the Xendit URL - mux.HandleFunc("POST /api/topup/create-session/{username}", userHandler.CreateXenditInvoice) + mux.Handle("POST /api/topup/create-session/{username}", requireCustomer(http.HandlerFunc(userHandler.CreateXenditInvoice))) // Payment gateway endpoints // Xendit's servers call this behind the scenes when the payment is done - mux.HandleFunc("POST /api/webhooks/xendit", userHandler.XenditWebhook) - mux.HandleFunc("POST /v1/user/{username}/topup/checkout", userHandler.CreateXenditInvoice) // - //mux.HandleFunc("GET /v1/user/{username}/topup/success", userHandler.TopUpSuccessHandler) - mux.HandleFunc("GET /u/{username}/transaction", userHandler.TransactionView) - mux.HandleFunc("GET /u/{username}/transactions", userHandler.TransactionView) + mux.HandleFunc("POST /api/webhooks/xendit/invoice", userHandler.XenditWebhook) + mux.HandleFunc("POST /api/webhooks/xendit/disbursement", merchantHandler.XenditDisbursementWebhook) + mux.Handle("POST /v1/user/{username}/topup/checkout", requireCustomer(http.HandlerFunc(userHandler.CreateXenditInvoice))) + mux.Handle("GET /u/{username}/transaction", requireCustomer(http.HandlerFunc(userHandler.TransactionView))) + mux.Handle("GET /u/{username}/transactions", requireCustomer(http.HandlerFunc(userHandler.TransactionView))) - mux.HandleFunc("GET /v1/user/{username}", userHandler.DashboardHandler) - mux.HandleFunc("GET /v1/user/{username}/transactions", userHandler.TransactionsJSONHandler) - //mux.HandleFunc("GET /logout",) + mux.Handle("GET /v1/user/{username}", requireCustomer(http.HandlerFunc(userHandler.DashboardHandler))) + mux.Handle("GET /v1/user/{username}/transactions", requireCustomer(http.HandlerFunc(userHandler.TransactionsJSONHandler))) + mux.HandleFunc("GET /logout", authHandler.LogoutHandler) + mux.HandleFunc("POST /logout", authHandler.LogoutHandler) // Allow POST as well just in cases // merchant endpoints - mux.HandleFunc("GET /merchant/{username}/dashboard", merchantHandler.MerchantDashboardView) - mux.HandleFunc("GET /v1/merchant/{username}/dashboard", merchantHandler.MerchantDashboardDataHandler) - mux.HandleFunc("GET /merchant/{username}/transactions", merchantHandler.MerchantTransactionsView) - mux.HandleFunc("GET /v1/merchant/{username}/transactions", merchantHandler.TransactionHandler) - - mux.HandleFunc("GET /v1/merchant/{username}/incomes", merchantHandler.IncomeHandler) - mux.HandleFunc("GET /merchant/{username}/account", merchantHandler.MerchantAccountView) - mux.HandleFunc("GET /v1/merchant/{username}/account", merchantHandler.MerchantAccountDataHandler) - mux.HandleFunc("POST /v1/merchant/{username}/withdraw", merchantHandler.WithdrawHandler) + mux.Handle("GET /merchant/{username}/dashboard", requireMerchant(http.HandlerFunc(merchantHandler.MerchantDashboardView))) + mux.Handle("GET /v1/merchant/{username}/dashboard", requireMerchant(http.HandlerFunc(merchantHandler.MerchantDashboardDataHandler))) + mux.Handle("GET /merchant/{username}/transactions", requireMerchant(http.HandlerFunc(merchantHandler.MerchantTransactionsView))) + mux.Handle("GET /v1/merchant/{username}/transactions", requireMerchant(http.HandlerFunc(merchantHandler.TransactionHandler))) + + mux.Handle("GET /v1/merchant/{username}/incomes", requireMerchant(http.HandlerFunc(merchantHandler.IncomeHandler))) + mux.Handle("GET /merchant/{username}/account", requireMerchant(http.HandlerFunc(merchantHandler.MerchantAccountView))) + mux.Handle("GET /v1/merchant/{username}/account", requireMerchant(http.HandlerFunc(merchantHandler.MerchantAccountDataHandler))) + mux.Handle("POST /v1/merchant/{username}/update-bank", requireMerchant(http.HandlerFunc(merchantHandler.UpdateBankDetails))) + mux.Handle("POST /v1/merchant/{username}/upload-document", requireMerchant(http.HandlerFunc(merchantHandler.UploadDocument))) + mux.Handle("POST /v1/merchant/{username}/withdraw", requireMerchant(http.HandlerFunc(merchantHandler.WithdrawHandler))) // super admin endpoints - mux.HandleFunc("GET /admin/{username}", adminHanlder.AdminDashboardView) - mux.HandleFunc("GET /v1/admin/{username}/dashboard-data", adminHanlder.AdminDashboardDataHandler) - mux.HandleFunc("GET /admin/{username}/merchants", adminHanlder.MerchantManagementView) - mux.HandleFunc("GET /v1/admin/{username}/merchants-data", adminHanlder.MerchantManagementDataHandler) - mux.HandleFunc("GET /admin/{username}/terminals", adminHanlder.TerminalRegistryView) - mux.HandleFunc("GET /v1/admin/{username}/terminals-data", adminHanlder.TerminalRegistryDataHandler) - mux.HandleFunc("GET /v1/admin/{username}/terminals/unassigned", adminHanlder.GetUnassignedTerminalsHandler) - mux.HandleFunc("POST /v1/admin/{username}/terminals/add", adminHanlder.AddTerminalHandler) - mux.HandleFunc("GET /admin/{username}/settings", adminHanlder.SystemSettingsView) - mux.HandleFunc("GET /admin/{username}/transactions", adminHanlder.TransactionsView) - mux.HandleFunc("GET /v1/admin/{username}/transactions", adminHanlder.AllTransactionsJSONHandler) - mux.HandleFunc("POST /v1/admin/{username}/merchants/add", adminHanlder.AddMerchantHandler) - mux.HandleFunc("GET /admin/{username}/merchants/{id}", adminHanlder.MerchantInfoView) - mux.HandleFunc("GET /v1/admin/{username}/merchants/{id}/data", adminHanlder.MerchantInfoDataHandler) - mux.HandleFunc("POST /v1/admin/{username}/merchants/{id}/approve", adminHanlder.ApproveMerchantHandler) - mux.HandleFunc("POST /v1/admin/{username}/merchants/{id}/reject", adminHanlder.RejectMerchantHandler) - mux.HandleFunc("POST /v1/admin/{username}/merchants/{id}/suspend", adminHanlder.SuspendMerchantHandler) - mux.HandleFunc("DELETE /v1/admin/{username}/merchants/{id}/delete", adminHanlder.DeleteMerchantHandler) - mux.HandleFunc("GET /admin/{username}/card-inventory", adminHanlder.CardInventoryView) - mux.HandleFunc("GET /v1/admin/{username}/card-inventory-data", adminHanlder.CardInventoryDataHandler) - mux.HandleFunc("POST /v1/admin/{username}/cards/{id}/block", adminHanlder.BlockCardHandler) - mux.HandleFunc("GET /admin/{username}/addcard", adminHanlder.AddCardsView) - mux.HandleFunc("GET /admin/{username}/deactivatecard", adminHanlder.DeactivateView) - mux.HandleFunc("POST /v1/admin/{username}/addcardauth", adminHanlder.AddCardHandler) - mux.HandleFunc("POST /v1/admin/{username}/deactivatecardauth", adminHanlder.DeactivateCardHanlder) - mux.HandleFunc("POST /v1/admin/{username}/deletecardauth", adminHanlder.DeleteCardHandler) - mux.HandleFunc("GET /admin/{username}/delete-cards", adminHanlder.DeleteCardView) + mux.Handle("GET /admin/{username}", requireAdmin(http.HandlerFunc(adminHanlder.AdminDashboardView))) + mux.Handle("GET /v1/admin/{username}/dashboard-data", requireAdmin(http.HandlerFunc(adminHanlder.AdminDashboardDataHandler))) + mux.Handle("GET /admin/{username}/merchants", requireAdmin(http.HandlerFunc(adminHanlder.MerchantManagementView))) + mux.Handle("GET /v1/admin/{username}/merchants-data", requireAdmin(http.HandlerFunc(adminHanlder.MerchantManagementDataHandler))) + mux.Handle("GET /admin/{username}/terminals", requireAdmin(http.HandlerFunc(adminHanlder.TerminalRegistryView))) + mux.Handle("GET /v1/admin/{username}/terminals-data", requireAdmin(http.HandlerFunc(adminHanlder.TerminalRegistryDataHandler))) + mux.Handle("GET /v1/admin/{username}/terminals/unassigned", requireAdmin(http.HandlerFunc(adminHanlder.GetUnassignedTerminalsHandler))) + mux.Handle("POST /v1/admin/{username}/terminals/add", requireAdmin(http.HandlerFunc(adminHanlder.AddTerminalHandler))) + mux.Handle("GET /admin/{username}/settings", requireAdmin(http.HandlerFunc(adminHanlder.SystemSettingsView))) + mux.Handle("GET /admin/{username}/transactions", requireAdmin(http.HandlerFunc(adminHanlder.TransactionsView))) + mux.Handle("GET /v1/admin/{username}/transactions", requireAdmin(http.HandlerFunc(adminHanlder.AllTransactionsJSONHandler))) + mux.Handle("POST /v1/admin/{username}/merchants/add", requireAdmin(http.HandlerFunc(adminHanlder.AddMerchantHandler))) + mux.Handle("GET /admin/{username}/merchants/{id}", requireAdmin(http.HandlerFunc(adminHanlder.MerchantInfoView))) + mux.Handle("GET /v1/admin/{username}/merchants/{id}/data", requireAdmin(http.HandlerFunc(adminHanlder.MerchantInfoDataHandler))) + mux.Handle("POST /v1/admin/{username}/merchants/{id}/approve", requireAdmin(http.HandlerFunc(adminHanlder.ApproveMerchantHandler))) + mux.Handle("POST /v1/admin/{username}/merchants/{id}/reject", requireAdmin(http.HandlerFunc(adminHanlder.RejectMerchantHandler))) + mux.Handle("POST /v1/admin/{username}/merchants/{id}/suspend", requireAdmin(http.HandlerFunc(adminHanlder.SuspendMerchantHandler))) + mux.Handle("DELETE /v1/admin/{username}/merchants/{id}/delete", requireAdmin(http.HandlerFunc(adminHanlder.DeleteMerchantHandler))) + mux.Handle("GET /admin/{username}/card-inventory", requireAdmin(http.HandlerFunc(adminHanlder.CardInventoryView))) + mux.Handle("GET /v1/admin/{username}/card-inventory-data", requireAdmin(http.HandlerFunc(adminHanlder.CardInventoryDataHandler))) + mux.Handle("POST /v1/admin/{username}/cards/{id}/block", requireAdmin(http.HandlerFunc(adminHanlder.BlockCardHandler))) + mux.Handle("GET /admin/{username}/addcard", requireAdmin(http.HandlerFunc(adminHanlder.AddCardsView))) + mux.Handle("GET /admin/{username}/deactivatecard", requireAdmin(http.HandlerFunc(adminHanlder.DeactivateView))) + mux.Handle("POST /v1/admin/{username}/addcardauth", requireAdmin(http.HandlerFunc(adminHanlder.AddCardHandler))) + mux.Handle("POST /v1/admin/{username}/deactivatecardauth", requireAdmin(http.HandlerFunc(adminHanlder.DeactivateCardHanlder))) + mux.Handle("POST /v1/admin/{username}/deletecardauth", requireAdmin(http.HandlerFunc(adminHanlder.DeleteCardHandler))) + mux.Handle("GET /admin/{username}/delete-cards", requireAdmin(http.HandlerFunc(adminHanlder.DeleteCardView))) // terminal endpoints for Fare and Retails. - mux.HandleFunc("GET /terminal-sim", adminHanlder.TerminalSimView) + mux.HandleFunc("GET /terminal-sim", adminHanlder.TerminalSimView) // kept public since it's a sim mux.HandleFunc("POST /v1/terminal-sim/transact", adminHanlder.TerminalSimTransactionHandler) // Wrap mux with custom handler for root redirect @@ -171,8 +183,8 @@ func main() { }) // Start Server - fmt.Println("Server started on: http://" + serverAddress + port) - if err := http.ListenAndServe(serverAddress+port, customHandler); err != nil { + fmt.Println("Server started on: http://" + serverAddress + ":" + port) + if err := http.ListenAndServe(serverAddress+":"+port, customHandler); err != nil { log.Fatal(err) } } diff --git a/backend/internal/admin/admin_merchant.go b/backend/internal/admin/admin_merchant.go index 29ceb2c..ec1761f 100644 --- a/backend/internal/admin/admin_merchant.go +++ b/backend/internal/admin/admin_merchant.go @@ -9,6 +9,7 @@ import ( "os" "strconv" "strings" + "time" jsonwrite "unicard-go/backend/internal/pkg/handler" smtp "unicard-go/backend/internal/pkg/smtpbody" structs "unicard-go/backend/internal/pkg/structs" @@ -190,9 +191,9 @@ func (h *Handler) MerchantManagementDataHandler(w http.ResponseWriter, r *http.R } type ApproveMerchantRequest struct { - CommissionRate string `json:"commissionRate" validate:"required"` - TerminalSn string `json:"terminalSn" validate:"required"` - DeviceName string `json:"deviceName"` + CommissionRate string `json:"commissionRate" validate:"required"` + TerminalSn string `json:"terminalSn" validate:"required"` + DeviceName string `json:"deviceName"` } func (h *Handler) ApproveMerchantHandler(w http.ResponseWriter, r *http.Request) { @@ -226,9 +227,9 @@ func (h *Handler) ApproveMerchantHandler(w http.ResponseWriter, r *http.Request) return } - // Get merchant user_id, email, and owner_name for the notification email - var merchantUserID, merchantEmail, ownerName string - err = tx.QueryRow("SELECT user_id, business_email, owner_name FROM merchants WHERE merchant_id = ?", merchantID).Scan(&merchantUserID, &merchantEmail, &ownerName) + // Get merchant user_id, email, owner_name, and business_name for the notification email and welcome tx + var merchantUserID, merchantEmail, ownerName, businessName string + err = tx.QueryRow("SELECT user_id, business_email, owner_name, business_name FROM merchants WHERE merchant_id = ?", merchantID).Scan(&merchantUserID, &merchantEmail, &ownerName, &businessName) if err != nil { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant not found"}) return @@ -239,6 +240,8 @@ func (h *Handler) ApproveMerchantHandler(w http.ResponseWriter, r *http.Request) _, err = tx.Exec(` UPDATE merchants SET status = 'active', + document_status = 'approved', + message = 'Congratulations! Your UniCard Merchant Account is now fully active.', commission_rate = 2.00, approved_by = ?, approved_at = CURRENT_TIMESTAMP @@ -268,6 +271,24 @@ func (h *Handler) ApproveMerchantHandler(w http.ResponseWriter, r *http.Request) return } + // Format business name safely for transaction ID (remove spaces, uppercase) + /*safeBusinessName := strings.ToUpper(strings.ReplaceAll(businessName, " ", "")) + if len(safeBusinessName) > 15 { + safeBusinessName = safeBusinessName[:15] + }*/ + + // Insert welcome transaction + welcomeTxnID := fmt.Sprintf("Welcome %s-%d", businessName, time.Now().UnixMilli()) + _, err = tx.Exec(` + INSERT INTO transactions + (transaction_id, merchant_id, transaction_type, amount, points_earned, service_fee, status, description) + VALUES (?, ?, 'payment', NULL, NULL, NULL, 'completed', 'Welcome to UniCard! Your merchant account is now approved and ready to accept transactions.')`, + welcomeTxnID, merchantID) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to create welcome transaction"}) + return + } + if err := tx.Commit(); err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to finalize approval"}) return @@ -338,7 +359,7 @@ func (h *Handler) RejectMerchantHandler(w http.ResponseWriter, r *http.Request) return } - _, err = tx.Exec("UPDATE merchants SET status = 'rejected' WHERE merchant_id = ?", merchantID) + _, err = tx.Exec("UPDATE merchants SET status = 'rejected', document_status = 'rejected', message = ? WHERE merchant_id = ?", req.Reason, merchantID) if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to reject merchant"}) return @@ -419,7 +440,7 @@ func (h *Handler) SuspendMerchantHandler(w http.ResponseWriter, r *http.Request) return } - _, err = tx.Exec("UPDATE merchants SET status = 'suspended' WHERE merchant_id = ?", merchantID) + _, err = tx.Exec("UPDATE merchants SET status = 'suspended', message = ? WHERE merchant_id = ?", req.Reason, merchantID) if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to suspend merchant"}) return @@ -481,8 +502,8 @@ func (h *Handler) DeleteMerchantHandler(w http.ResponseWriter, r *http.Request) } defer tx.Rollback() - var merchantUserID string - err = tx.QueryRow("SELECT user_id FROM merchants WHERE merchant_id = ?", merchantID).Scan(&merchantUserID) + var merchantUserID, ownerName, merchantEmail string + err = tx.QueryRow("SELECT user_id, owner_name, business_email FROM merchants WHERE merchant_id = ?", merchantID).Scan(&merchantUserID, &ownerName, &merchantEmail) if err != nil { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant not found"}) return @@ -495,15 +516,15 @@ func (h *Handler) DeleteMerchantHandler(w http.ResponseWriter, r *http.Request) return } - // Delete from merchants - _, err = tx.Exec("DELETE FROM merchants WHERE merchant_id = ?", merchantID) + // Soft delete from merchants: Set status to 'deleted' and update message + _, err = tx.Exec("UPDATE merchants SET status = 'deleted', message = 'Your merchant account has been permanently deleted.' WHERE merchant_id = ?", merchantID) if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to delete merchant"}) return } - // Delete from users - _, err = tx.Exec("DELETE FROM users WHERE user_id = ?", merchantUserID) + // Soft delete from users: Set status to 'inactive' + _, err = tx.Exec("UPDATE users SET status = 'inactive' WHERE user_id = ?", merchantUserID) if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to delete user"}) return @@ -514,28 +535,59 @@ func (h *Handler) DeleteMerchantHandler(w http.ResponseWriter, r *http.Request) return } + // Send deletion email to merchant + go func(email, name string) { + smtpHost := os.Getenv("SMTP_HOST") + smtpPort := 587 + smtpEmail := os.Getenv("SMTP_EMAIL") + smtpSender := os.Getenv("SMTP_SENDER") + smtpPass := os.Getenv("SMTP_PASSWORD") + if smtpHost == "" || smtpEmail == "" { + log.Println("SMTP credentials not configured, skipping deletion email") + return + } + + m := gomail.NewMessage() + m.SetHeader("From", fmt.Sprintf("%s <%s>", smtpSender, smtpEmail)) + m.SetHeader("To", email) + m.SetHeader("Subject", "Unicard Account Deleted") + + htmlBody := fmt.Sprintf(smtp.MerchantDeletedEmail(), name) + m.SetBody("text/html", htmlBody) + + d := gomail.NewDialer(smtpHost, smtpPort, smtpEmail, smtpPass) + if err := d.DialAndSend(m); err != nil { + log.Printf("Failed to send deletion email to %s: %v", email, err) + } else { + log.Printf("Deletion email sent successfully to %s", email) + } + }(merchantEmail, ownerName) + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{Success: true, Message: "Merchant deleted successfully"}) } type MerchantDetailsData struct { - MerchantID string - UserID string - BusinessName string - BusinessType string - RegistrationNum string - BusinessAddress string - OwnerName string - BusinessEmail string - BusinessPhone string - Status string - CommissionRate float64 - SettlementBank string - SettlementName string - SettlementAcct string - CreatedAt string - DtiDocument string - BirDocument string - OtherDocument string + MerchantID string + UserID string + BusinessName string + BusinessType string + RegistrationNum string + BusinessAddress string + City string + PostalCode string + OwnerName string + BusinessEmail string + BusinessPhone string + Status string + CommissionRate float64 + SettlementBank string + SettlementName string + SettlementAcct string + CreatedAt string + BusinessDocument string + BirDocument string + OtherDocument string + DocumentStatus string } type MerchantInfoViewData struct { @@ -570,19 +622,19 @@ func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request var m MerchantDetailsData var commRate sql.NullFloat64 - var setBank, setName, setAcct, regNum, dtiDoc, birDoc, otherDoc sql.NullString + var setBank, setName, setAcct, regNum, dtiDoc, birDoc, otherDoc, city, postal, docStatus sql.NullString err := h.DB.QueryRow(` SELECT merchant_id, user_id, business_name, business_type, business_registration_number, - business_address, owner_name, business_email, business_phone, status, + business_address, city, postal_code, owner_name, business_email, business_phone, status, commission_rate, settlement_bank_name, settlement_account_name, settlement_account_number, created_at, - dti_document, bir_document, other_document + business_document, bir_document, other_document, document_status FROM merchants WHERE merchant_id = ?`, merchantID).Scan( &m.MerchantID, &m.UserID, &m.BusinessName, &m.BusinessType, ®Num, - &m.BusinessAddress, &m.OwnerName, &m.BusinessEmail, &m.BusinessPhone, &m.Status, + &m.BusinessAddress, &city, &postal, &m.OwnerName, &m.BusinessEmail, &m.BusinessPhone, &m.Status, &commRate, &setBank, &setName, &setAcct, &m.CreatedAt, - &dtiDoc, &birDoc, &otherDoc, + &dtiDoc, &birDoc, &otherDoc, &docStatus, ) if err != nil { @@ -604,6 +656,12 @@ func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request if regNum.Valid { m.RegistrationNum = regNum.String } + if city.Valid { + m.City = city.String + } + if postal.Valid { + m.PostalCode = postal.String + } if commRate.Valid { m.CommissionRate = commRate.Float64 @@ -618,7 +676,7 @@ func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request m.SettlementAcct = setAcct.String } if dtiDoc.Valid { - m.DtiDocument = dtiDoc.String + m.BusinessDocument = dtiDoc.String } if birDoc.Valid { m.BirDocument = birDoc.String @@ -626,6 +684,9 @@ func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request if otherDoc.Valid { m.OtherDocument = otherDoc.String } + if docStatus.Valid { + m.DocumentStatus = docStatus.String + } jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ Success: true, diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go new file mode 100644 index 0000000..6256a61 --- /dev/null +++ b/backend/internal/auth/jwt.go @@ -0,0 +1,88 @@ +package authentication + +import ( + "fmt" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var jwtSecret = []byte(getEnv("JWT_SECRET", "super-secret-key")) // fallback for dev + +func getEnv(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback +} + +type JWTClaims struct { + UserID string `json:"user_id"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +// GenerateTokens creates a short-lived access token and a long-lived refresh token +func GenerateTokens(userID, role string) (string, string, error) { + // Access Token (15 minutes) + accessExpiration := time.Now().Add(15 * time.Minute) + accessClaims := &JWTClaims{ + UserID: userID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(accessExpiration), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "unicard-auth", + Subject: "access", + }, + } + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString(jwtSecret) + if err != nil { + return "", "", err + } + + // Refresh Token (7 days) + refreshExpiration := time.Now().Add(7 * 24 * time.Hour) + refreshClaims := &JWTClaims{ + UserID: userID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(refreshExpiration), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "unicard-auth", + Subject: "refresh", + }, + } + refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString(jwtSecret) + if err != nil { + return "", "", err + } + + return accessToken, refreshToken, nil +} + +// ValidateJWT parses and validates the JWT, returning the claims if valid. +func ValidateJWT(tokenString string) (*JWTClaims, error) { + claims := &JWTClaims{} + + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + // Ensure the signing method is what we expect + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return jwtSecret, nil + }) + + if err != nil { + return nil, err + } + + if !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + return claims, nil +} diff --git a/backend/internal/auth/login.go b/backend/internal/auth/login.go index ed96570..1ed277d 100644 --- a/backend/internal/auth/login.go +++ b/backend/internal/auth/login.go @@ -5,6 +5,7 @@ import ( "errors" "log" "net/http" + "time" jsonwrite "unicard-go/backend/internal/pkg/handler" "github.com/go-playground/validator/v10" // For struct validation @@ -114,6 +115,39 @@ func (h *Handler) LoginAuthHandler(w http.ResponseWriter, r *http.Request) { redirectURL = "/merchant/" + userName + "/dashboard" // Merchant dashboard } + // Generate JWT Tokens (Access & Refresh) + accessToken, refreshToken, err := GenerateTokens(ID, role) + if err != nil { + log.Printf("Error generating tokens: %v", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Internal server error during login", + }) + return + } + + // Set Access Token as HttpOnly cookie + http.SetCookie(w, &http.Cookie{ + Name: "jwt", + Value: accessToken, + Expires: time.Now().Add(1 * time.Minute), // 15 minutes expiration + HttpOnly: true, + Secure: true, // Important for SameSite=StrictMode + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + + // Set Refresh Token as HttpOnly cookie + http.SetCookie(w, &http.Cookie{ + Name: "refresh_token", + Value: refreshToken, + Expires: time.Now().Add(7 * 24 * time.Hour), // 7 days expiration + HttpOnly: true, + Secure: true, // Important for SameSite=StrictMode + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + // SUCCESS User Login log.Printf("Login success for user: %s", loginReq.Identifier) jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.LoginResponse{ diff --git a/backend/internal/auth/logout.go b/backend/internal/auth/logout.go new file mode 100644 index 0000000..94fcab4 --- /dev/null +++ b/backend/internal/auth/logout.go @@ -0,0 +1,34 @@ +package authentication + +import ( + "net/http" + "time" +) + +// LogoutHandler clears the authentication cookies and redirects the user to the login page. +func (h *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) { + // Clear the access token cookie + http.SetCookie(w, &http.Cookie{ + Name: "jwt", + Value: "", + Expires: time.Unix(0, 0), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + + // Clear the refresh token cookie + http.SetCookie(w, &http.Cookie{ + Name: "refresh_token", + Value: "", + Expires: time.Unix(0, 0), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + + // Redirect to login page + http.Redirect(w, r, "/login", http.StatusSeeOther) +} diff --git a/backend/internal/auth/merchant_signup.go b/backend/internal/auth/merchant_signup.go index 896806f..c4d5d8b 100644 --- a/backend/internal/auth/merchant_signup.go +++ b/backend/internal/auth/merchant_signup.go @@ -20,20 +20,20 @@ import ( ) type MerchantSignupRequest struct { - BusinessName string `json:"businessName" validate:"required"` - BusinessType string `json:"businessType" validate:"required"` - BusinessAddress string `json:"businessAddress" validate:"required"` - OwnerName string `json:"ownerName" validate:"required"` - BusinessPhone string `json:"businessPhone" validate:"required"` - BusinessEmail string `json:"businessEmail" validate:"required,email"` - Password string `json:"password" validate:"required,min=6"` - DtiDocument string `json:"dtiDocument"` - BirDocument string `json:"birDocument"` - OtherDocument string `json:"otherDocument"` + BusinessName string `json:"businessName" validate:"required"` + BusinessType string `json:"businessType" validate:"required"` + BusinessAddress string `json:"businessAddress" validate:"required"` + OwnerName string `json:"ownerName" validate:"required"` + BusinessPhone string `json:"businessPhone" validate:"required"` + BusinessEmail string `json:"businessEmail" validate:"required,email"` + Password string `json:"password" validate:"required,min=6"` + BusinessDocument string `json:"businessDocument"` + BirDocument string `json:"birDocument"` + OtherDocument string `json:"otherDocument"` } // Helper to save base64 to file -func saveBase64ToFile(b64data, merchantID, docType string) string { +func saveBase64ToFile(b64data string) string { if b64data == "" { return "" } @@ -53,9 +53,9 @@ func saveBase64ToFile(b64data, merchantID, docType string) string { } // Create directory if not exists os.MkdirAll("./storage/documents", os.ModePerm) - fileName := fmt.Sprintf("%s_%s_%d%s", merchantID, docType, time.Now().Unix(), ext) + fileName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext) filePath := filepath.Join("./storage/documents", fileName) - + err = os.WriteFile(filePath, data, 0644) if err != nil { return "" @@ -184,23 +184,23 @@ func (h *Handler) MerchantSignupHandler(w http.ResponseWriter, r *http.Request) regNum := fmt.Sprintf("UCBZ-%s-%010d", time.Now().Format("010205"), nReg.Int64()) // Save Documents - dtiPath := saveBase64ToFile(req.DtiDocument, merchantID, "DTI") - birPath := saveBase64ToFile(req.BirDocument, merchantID, "BIR") - otherPath := saveBase64ToFile(req.OtherDocument, merchantID, "OTHER") + bizDocPath := saveBase64ToFile(req.BusinessDocument) + birPath := saveBase64ToFile(req.BirDocument) + otherPath := saveBase64ToFile(req.OtherDocument) // Insert Merchant with placeholder 'PENDING' for settlement fields fixedCommissionRate := 2.00 merchStmt := `INSERT INTO merchants ( merchant_id, business_name, business_type, business_registration_number, business_address, user_id, owner_name, business_email, business_phone, commission_rate, - status, dti_document, bir_document, other_document + status, business_document, bir_document, other_document ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - + _, err = tx.ExecContext(ctx, merchStmt, merchantID, req.BusinessName, req.BusinessType, regNum, req.BusinessAddress, userID, req.OwnerName, req.BusinessEmail, req.BusinessPhone, fixedCommissionRate, "pending approval", - dtiPath, birPath, otherPath, + bizDocPath, birPath, otherPath, ) if err != nil { diff --git a/backend/internal/auth/refresh.go b/backend/internal/auth/refresh.go new file mode 100644 index 0000000..fb6f764 --- /dev/null +++ b/backend/internal/auth/refresh.go @@ -0,0 +1,71 @@ +package authentication + +import ( + "log" + "net/http" + "time" + jsonwrite "unicard-go/backend/internal/pkg/handler" +) + +// RefreshTokenHandler handles the generation of new access tokens using a valid refresh token. +func (h *Handler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) { + // 1. Get the refresh token from the cookie + cookie, err := r.Cookie("refresh_token") + if err != nil { + jsonwrite.WriteJSON(w, http.StatusUnauthorized, jsonwrite.APIResponse{ + Success: false, + Message: "Unauthorized: Missing refresh token", + }) + return + } + + // 2. Validate the refresh token + claims, err := ValidateJWT(cookie.Value) + if err != nil || claims.Subject != "refresh" { + log.Printf("Invalid refresh token attempt: %v", err) + jsonwrite.WriteJSON(w, http.StatusUnauthorized, jsonwrite.APIResponse{ + Success: false, + Message: "Unauthorized: Invalid or expired refresh token", + }) + return + } + + // 3. Generate new tokens + accessToken, newRefreshToken, err := GenerateTokens(claims.UserID, claims.Role) + if err != nil { + log.Printf("Error generating tokens during refresh: %v", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Internal server error", + }) + return + } + + // 4. Set the new Access Token cookie + http.SetCookie(w, &http.Cookie{ + Name: "jwt", + Value: accessToken, + Expires: time.Now().Add(15 * time.Minute), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + + // 5. Set the new Refresh Token cookie (sliding expiration) + http.SetCookie(w, &http.Cookie{ + Name: "refresh_token", + Value: newRefreshToken, + Expires: time.Now().Add(7 * 24 * time.Hour), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + + // 6. Return success response + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, + Message: "Token refreshed successfully", + }) +} diff --git a/backend/internal/merchant/account.go b/backend/internal/merchant/account.go index 7e47b67..2a9ce09 100644 --- a/backend/internal/merchant/account.go +++ b/backend/internal/merchant/account.go @@ -1,8 +1,15 @@ package merchant import ( + "encoding/json" + "fmt" + "io" "log" "net/http" + "os" + "path/filepath" + "strings" + "time" jsonwrite "unicard-go/backend/internal/pkg/handler" ) @@ -19,8 +26,8 @@ type BusinessDetails struct { type BusinessDocument struct { DocumentType string `json:"document_type"` - Status string `json:"status"` - Message string `json:"message,omitempty"` + Status string `json:"document_status"` + Message string `json:"message"` BusinessStructure string `json:"business_structure"` DocumentURL string `json:"document_url"` } @@ -34,12 +41,36 @@ type BusinessBankDetails struct { type AccountSummary struct { MerchantID string `json:"merchant_id"` AccountStatus string `json:"account_status"` + DocumentStatus string `json:"document_status"` + AccountMessage string `json:"account_message"` MemberSince string `json:"member_since"` BusinessDetails BusinessDetails `json:"business_details"` BusinessBankDetails BusinessBankDetails `json:"business_bank_details"` BusinessDocuments []BusinessDocument `json:"business_document"` } +type MerchantDetails struct { + merchantID string `db:"merchant_id"` + accountStatus string `db:"status"` + businessName string `db:"business_name"` + businessType string `db:"business_type"` + businessStructure string `db:"business_structure"` + businessEmail string `db:"business_email"` + businessPhone string `db:"business_phone"` + businessAddress string `db:"business_address"` + city string `db:"city"` + postalCode string `db:"postal_code"` + accName string `db:"settlement_account_name"` + bankName string `db:"settlement_bank_name"` + accNumber string `db:"settlement_account_number"` + businessDoc string `db:"business_document,bir_document"` + birDoc string `db:"bir_document"` + otherDoc string `db:"other_document"` + docStatus string `db:"document_status"` + docMessage string `db:"message"` + createdAtStr string `db:"created_at"` +} + // MerchantAccountView renders the merchant_account.html template func (h *Handler) MerchantAccountView(w http.ResponseWriter, r *http.Request) { log.Println("MerchantAccountView running...") @@ -67,68 +98,43 @@ func (h *Handler) MerchantAccountDataHandler(w http.ResponseWriter, r *http.Requ return } - // 1. Holds the data fetched from the database - var ( - merchantID, accountStatus, businessName, businessType, businessStructure, - businessEmail, businessPhone, businessAddress, city, - postalCode, accName, bankName, accNumber, businessDoc, birDoc, - docStatus, docMessage, createdAtStr string - ) + // Holds the data fetched from the database + var merchant MerchantDetails - // 2. Execute the full JOIN query + // Execute the full JOIN query err := h.DB.QueryRowContext(ctx, ` SELECT m.merchant_id, COALESCE(m.status, ''), COALESCE(DATE_FORMAT(m.created_at, '%M %d, %Y'), '') as created_at, - -- Business Info COALESCE(m.business_name, ''), COALESCE(m.business_type, ''), - COALESCE(m.business_structure, ''), COALESCE(m.business_email, ''), COALESCE(m.business_phone, ''), COALESCE(m.business_address, ''), - -- Location Info COALESCE(m.city, ''), - COALESCE(m.postal_code, ''), - + COALESCE(m.postal_code, ''), -- Bank Info COALESCE(m.settlement_account_name, ''), COALESCE(m.settlement_bank_name, ''), COALESCE(m.settlement_account_number, ''), - -- Document Info - COALESCE(m.business_structure, ''), COALESCE(m.business_document, ''), COALESCE(m.bir_document, ''), + COALESCE(m.other_document, ''), COALESCE(m.document_status, ''), COALESCE(m.message, '') - FROM merchants m JOIN users u ON m.user_id = u.user_id WHERE u.username = ? `, username).Scan( - &merchantID, - &accountStatus, - &createdAtStr, - &businessName, - &businessType, - &businessStructure, - &businessEmail, - &businessPhone, - &businessAddress, - &city, - &postalCode, - &accName, - &bankName, - &accNumber, - &businessStructure, - &businessDoc, - &birDoc, - &docStatus, - &docMessage, + &merchant.merchantID, &merchant.accountStatus, &merchant.createdAtStr, &merchant.businessName, + &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, ) if err != nil { @@ -140,76 +146,217 @@ func (h *Handler) MerchantAccountDataHandler(w http.ResponseWriter, r *http.Requ return } - // 3. Format UI Logic - memberSince := createdAtStr + // Format UI Logic + memberSince := merchant.createdAtStr var maskedAccount string - if len(accNumber) > 4 { - maskedAccount = "**** **** **** " + accNumber[len(accNumber)-4:] - } else if accNumber != "" { - maskedAccount = accNumber + if len(merchant.accNumber) > 4 { + maskedAccount = "**** **** **** " + merchant.accNumber[len(merchant.accNumber)-4:] + } else if merchant.accNumber != "" { + maskedAccount = merchant.accNumber } - // 4. Update the dynamic Document Array logic + // Update the dynamic Document Array logic documents := []BusinessDocument{} // Business Registration (DTI or SEC) - if businessDoc != "" { - registrationLabel := "DTI Registration" - - // NOW checking the correct column! - if businessStructure == "corporation" || businessStructure == "partnership" { - registrationLabel = "SEC Registration" - } + if merchant.businessDoc != "" { + registrationLabel := "DTI/SEC Registration" documents = append(documents, BusinessDocument{ DocumentType: registrationLabel, - Status: docStatus, // Quotes removed! - Message: docMessage, // Quotes removed! - BusinessStructure: businessStructure, - DocumentURL: businessDoc, + Status: merchant.docStatus, // Quotes removed! + Message: merchant.docMessage, // Quotes removed! + BusinessStructure: merchant.businessStructure, + DocumentURL: merchant.businessDoc, }) } // Tax Registration (BIR) - if birDoc != "" { + if merchant.birDoc != "" { documents = append(documents, BusinessDocument{ DocumentType: "BIR Certificate", - Status: docStatus, // Quotes removed! - Message: docMessage, // Quotes removed! - DocumentURL: birDoc, + Status: merchant.docStatus, + Message: merchant.docMessage, + DocumentURL: merchant.birDoc, }) } - // 5. Construct the final struct - responseData := AccountSummary{ - MerchantID: merchantID, - AccountStatus: accountStatus, - MemberSince: memberSince, + // Other Document + if merchant.otherDoc != "" { + documents = append(documents, BusinessDocument{ + DocumentType: "Other Document", + Status: merchant.docStatus, + Message: merchant.docMessage, + DocumentURL: merchant.otherDoc, + }) + } + // Construct the final struct + responseData := AccountSummary{ + MerchantID: merchant.merchantID, + AccountStatus: merchant.accountStatus, + DocumentStatus: merchant.docStatus, + AccountMessage: merchant.docMessage, + MemberSince: memberSince, BusinessDetails: BusinessDetails{ - BusinessName: businessName, - BusinessType: businessType, - BusinessEmail: businessEmail, - BusinessPhone: businessPhone, - BusinessAddress: businessAddress, - City: city, - PostalCode: postalCode, + BusinessName: merchant.businessName, + BusinessType: merchant.businessType, + BusinessEmail: merchant.businessEmail, + BusinessPhone: merchant.businessPhone, + BusinessAddress: merchant.businessAddress, + City: merchant.city, + PostalCode: merchant.postalCode, }, - BusinessBankDetails: BusinessBankDetails{ - AccountHolderName: accName, - BankName: bankName, + AccountHolderName: merchant.accName, + BankName: merchant.bankName, AccountNumber: maskedAccount, }, - BusinessDocuments: documents, } - // 6. Send response + // Send response jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ Success: true, Message: "Account profile retrieved successfully", Data: responseData, }) } + +func (h *Handler) UpdateBankDetails(w http.ResponseWriter, r *http.Request) { + log.Println("UpdateBankDetails running...") + username := r.PathValue("username") + if username == "" { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Username required"}) + return + } + + 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 + } + + 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 + } + + 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) + 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) + + if err != nil { + log.Println("Update error:", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to update bank details"}) + return + } + + // Insert a system transaction to log the update + sysTxnID := fmt.Sprintf("SYS-SETTLE-%d", time.Now().UnixMilli()) + _, _ = h.DB.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.')`, + sysTxnID, merchantID) + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{Success: true, Message: "Bank details updated"}) +} + +func (h *Handler) UploadDocument(w http.ResponseWriter, r *http.Request) { + log.Println("UploadDocument running...") + username := r.PathValue("username") + if username == "" { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Username required"}) + return + } + + err := r.ParseMultipartForm(4 << 20) // Limit memory to 4MB + if err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "File too large"}) + return + } + + docType := r.FormValue("document_type") + file, handler, err := r.FormFile("document") + if err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Failed to read file"}) + return + } + defer file.Close() + + if handler.Size > 4*1024*1024 { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "File too large. Maximum size is 4MB."}) + return + } + + ext := strings.ToLower(filepath.Ext(handler.Filename)) + validExts := map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".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."}) + return + } + + // Ensure uploads directory exists + uploadDir := "storage/documents" + os.MkdirAll(uploadDir, os.ModePerm) + + // Save file with auto-generated filename to eliminate raw filename + filename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext) + filePath := filepath.Join(uploadDir, filename) + dst, err := os.Create(filePath) + if err != nil { + log.Println("File create error:", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Internal server error"}) + return + } + defer dst.Close() + + if _, err := io.Copy(dst, file); err != nil { + log.Println("File copy error:", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Internal server error"}) + return + } + + dbPath := "/" + strings.ReplaceAll(filePath, "\\", "/") + + col := "business_document" + switch docType { + case "BIR Certificate": + col = "bir_document" + case "Other Document": + col = "other_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 { + oldFile := strings.TrimPrefix(*oldDbPath, "/") + if oldFile != "" { + os.Remove(oldFile) // Best effort delete + } + } + + 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) + 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"}) +} diff --git a/backend/internal/merchant/dashboard.go b/backend/internal/merchant/dashboard.go index a54a4c3..961cb7c 100644 --- a/backend/internal/merchant/dashboard.go +++ b/backend/internal/merchant/dashboard.go @@ -43,7 +43,8 @@ type MerchantSummary struct { AvailableBalance decimal.Decimal `json:"available_balance"` MonthlyNetIncome decimal.Decimal `json:"monthly_net_income"` SettlementBank *string `json:"settlement_bank"` - SettlementAccount *string `json:"settlement_account"` + SettlementAccount *string `json:"settlement_account_number"` + SettlementName *string `json:"settlement_account_name"` RecentTransactions []MerchantTransaction `json:"recent_transactions"` } @@ -83,16 +84,16 @@ func (h *Handler) GetMerchantRecentTransactions(ctx context.Context, merchantID log.Println("GetMerchantRecentTransactions running...") rows, err := h.DB.QueryContext(ctx, ` - SELECT + SELECT transaction_id, COALESCE(card_number, ''), merchant_id, terminal_id, - COALESCE(transaction_type, ''), amount, + COALESCE(transaction_type, ''), COALESCE(amount, 0), COALESCE(points_earned, 0), COALESCE(service_fee, 0), COALESCE(net_merchant_payout, 0), processed_by, COALESCE(status, ''), description, COALESCE(created_at, '') - FROM transactions - WHERE merchant_id = ? - ORDER BY created_at DESC + FROM transactions + WHERE merchant_id = ? + ORDER BY created_at DESC LIMIT 10`, merchantID) if err != nil { diff --git a/backend/internal/merchant/incomes.go b/backend/internal/merchant/incomes.go index 85f1dab..280fbc2 100644 --- a/backend/internal/merchant/incomes.go +++ b/backend/internal/merchant/incomes.go @@ -22,12 +22,6 @@ type IncomeHistory struct { TerminalID *string `json:"terminal_id" db:"terminal_id"` } -// IncomeStat represents the merchant's income statistics -// TotalCollected → Total money collected from customers (gross, before Unicard fee) -// UniCardFee → Unicard's platform cut deducted per transaction -// TotalEarned → What the merchant receives after Unicard's fee -// TotalRefunded → Money returned back to customers -// ActualIncome → What the merchant actually keeps after refunds (real bottom line) // IncomeStat represents the merchant's income statistics type IncomeStat struct { NetRevenue decimal.Decimal `json:"net_revenue"` // What the merchant gets after platform fee @@ -118,7 +112,7 @@ func (h *Handler) GetMerchantIncomeHistory(ctx context.Context, merchantID strin SELECT COALESCE(created_at, ''), description, transaction_id, COALESCE(card_number, ''), - COALESCE(transaction_type, ''), amount, + COALESCE(transaction_type, ''), COALESCE(amount, 0), COALESCE(net_merchant_payout, 0), COALESCE(service_fee, 0), processed_by, terminal_id FROM transactions diff --git a/backend/internal/merchant/transactions.go b/backend/internal/merchant/transactions.go index de47b46..7f7958c 100644 --- a/backend/internal/merchant/transactions.go +++ b/backend/internal/merchant/transactions.go @@ -61,7 +61,7 @@ func (h *Handler) TransactionHandler(w http.ResponseWriter, r *http.Request) { query := `SELECT transaction_id, COALESCE(card_number, ''), merchant_id, terminal_id, - COALESCE(transaction_type, ''), amount, + COALESCE(transaction_type, ''), COALESCE(amount, 0), COALESCE(points_earned, 0), COALESCE(service_fee, 0), COALESCE(net_merchant_payout, 0), processed_by, COALESCE(status, ''), description, COALESCE(created_at, '') diff --git a/backend/internal/merchant/withdraw.go b/backend/internal/merchant/withdraw.go index 9269a30..579c19e 100644 --- a/backend/internal/merchant/withdraw.go +++ b/backend/internal/merchant/withdraw.go @@ -1,18 +1,30 @@ package merchant import ( + "context" "database/sql" "encoding/json" "fmt" "log" "net/http" + "os" + "strings" "time" jsonwrite "unicard-go/backend/internal/pkg/handler" "github.com/shopspring/decimal" + xendit "github.com/xendit/xendit-go/v7" + "github.com/xendit/xendit-go/v7/payout" ) +type BankDetails struct { + merchantID string + settlementBank *string + settlementAccountName *string + settlementAccount *string +} + type WithdrawRequest struct { Amount float64 `json:"amount"` } @@ -21,7 +33,7 @@ type WithdrawRequest struct { func (h *Handler) WithdrawHandler(w http.ResponseWriter, r *http.Request) { log.Println("WithdrawHandler running...") - // 1. Get username from URL + // Get username from URL username := r.PathValue("username") if username == "" { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ @@ -31,7 +43,7 @@ func (h *Handler) WithdrawHandler(w http.ResponseWriter, r *http.Request) { return } - // 2. Parse request payload + // Parse request payload var req WithdrawRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ @@ -49,19 +61,14 @@ func (h *Handler) WithdrawHandler(w http.ResponseWriter, r *http.Request) { return } - // 3. Fetch Merchant Info (ID, Settlement Details) - var ( - merchantID string - settlementBank *string - settlementAccount *string - ) - + // Fetch Merchant Info (ID, Settlement Details) + var bank BankDetails err := h.DB.QueryRow(` - SELECT m.merchant_id, m.settlement_bank_name, m.settlement_account_number + 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 WHERE u.username = ? LIMIT 1 - `, username).Scan(&merchantID, &settlementBank, &settlementAccount) + `, username).Scan(&bank.merchantID, &bank.settlementBank, &bank.settlementAccountName, &bank.settlementAccount) if err != nil { if err == sql.ErrNoRows { @@ -80,7 +87,7 @@ func (h *Handler) WithdrawHandler(w http.ResponseWriter, r *http.Request) { } // Validate settlement details - if settlementBank == nil || settlementAccount == nil { + if bank.settlementBank == nil || bank.settlementAccount == nil || bank.settlementAccountName == nil { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ Success: false, Message: "Please set up your settlement bank account details in your profile before withdrawing.", @@ -88,8 +95,8 @@ func (h *Handler) WithdrawHandler(w http.ResponseWriter, r *http.Request) { return } - // 4. Calculate Available Balance - stats, err := h.GetMerchantIncomeStats(r.Context(), merchantID) + // Calculate Available Balance + stats, err := h.GetMerchantIncomeStats(r.Context(), bank.merchantID) if err != nil { log.Println("Error fetching income stats:", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ @@ -108,38 +115,75 @@ func (h *Handler) WithdrawHandler(w http.ResponseWriter, r *http.Request) { return } - // 5. Generate Transaction ID + // Generate Transaction ID // Format: TXN-WD-timestamp txnID := fmt.Sprintf("TXN-WD-%d", time.Now().UnixNano()) - accountNum := *settlementAccount + // getting the last 4 digit of bank account number + accountNum := *bank.settlementAccount last4 := accountNum if len(accountNum) > 4 { last4 = accountNum[len(accountNum)-4:] } - description := fmt.Sprintf("Withdrawal to %s ending in %s", *settlementBank, last4) - + description := fmt.Sprintf("Withdrawal to %s ending in %s", *bank.settlementBank, last4) + + // Create Xendit Payout (v7) + xenditClient := xendit.NewClient(os.Getenv("XENDIT_SECRET_KEY")) + payoutClient := payout.NewPayoutApi(xenditClient) + + channelProps := payout.NewDigitalPayoutChannelProperties(*bank.settlementAccount) + channelProps.SetAccountHolderName(*bank.settlementAccountName) + + createPayoutReq := payout.NewCreatePayoutRequest( + txnID, + "PH_"+strings.ToUpper(*bank.settlementBank), // Channel code e.g. "PH_MAYA" + *channelProps, + float32(req.Amount), + "PHP", + ) + createPayoutReq.SetDescription(description) + + _, _, payoutErr := payoutClient.CreatePayout(context.Background()). + IdempotencyKey(txnID). + CreatePayoutRequest(*createPayoutReq). + Execute() + + if payoutErr != nil { + log.Printf("Failed to create Xendit payout: %v", payoutErr.Error()) + log.Printf("Data sent to Xendit: %+v", createPayoutReq) + log.Printf("Xendit Detailed Error: %v", payoutErr.Error()) + + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to process withdrawal via payment gateway", + }) + return + } + insertTxnQuery := ` INSERT INTO transactions ( transaction_id, merchant_id, transaction_type, amount, status, description, card_number - ) VALUES (?, ?, 'withdrawal', ?, 'completed', ?, NULL) + ) VALUES (?, ?, 'withdrawal', ?, 'pending', ?, NULL) ` - _, err = h.DB.Exec(insertTxnQuery, txnID, merchantID, req.Amount, description) + _, err = h.DB.Exec(insertTxnQuery, txnID, bank.merchantID, req.Amount, description) 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. + // For now, we return an error. jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ Success: false, - Message: "Failed to process withdrawal", + Message: "Withdrawal initiated, but failed to record in database. Please contact support.", }) return } jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ Success: true, - Message: "Withdrawal processed successfully", + Message: "Withdrawal is being processed", Data: map[string]interface{}{ "transaction_id": txnID, "amount": req.Amount, + "status": "pending", }, }) } diff --git a/backend/internal/merchant/xendit_disbursement_webhook.go b/backend/internal/merchant/xendit_disbursement_webhook.go new file mode 100644 index 0000000..571baca --- /dev/null +++ b/backend/internal/merchant/xendit_disbursement_webhook.go @@ -0,0 +1,74 @@ +package merchant + +import ( + "encoding/json" + "io" + "log" + "net/http" + "os" +) + +// 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"` + } `json:"data"` +} + +// XenditDisbursementWebhook handles incoming webhook notifications from Xendit for disbursements. +func (h *Handler) XenditDisbursementWebhook(w http.ResponseWriter, r *http.Request) { + // Verify Xendit Callback Token + xenditToken := os.Getenv("XENDIT_WEBHOOK_KEY") + callbackToken := r.Header.Get("x-callback-token") + + if xenditToken != "" && callbackToken != xenditToken { + log.Println("Invalid x-callback-token for disbursement") + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + log.Println("Failed to read body") + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + var payload XenditPayoutWebhookPayload + if err := json.Unmarshal(body, &payload); err != nil { + log.Println("Failed to parse webhook JSON:", err) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + externalID := payload.Data.ReferenceID + + // 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) + 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) + + case "payout.failed": + _, err := h.DB.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) + return + } + log.Printf("Disbursement failed for transaction %s. Reason: %s", externalID, payload.Data.FailureCode) + } + + // Always 200 OK to Xendit + w.WriteHeader(http.StatusOK) +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..353365e --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -0,0 +1,131 @@ +package middleware + +import ( + "context" + "log" + "net/http" + "strings" + "time" + authentication "unicard-go/backend/internal/auth" + jsonwrite "unicard-go/backend/internal/pkg/handler" +) + +type contextKey string + +const UserClaimsKey contextKey = "user_claims" + +// RequireAuth is a middleware that checks for a valid JWT in the Authorization header +// or HttpOnly cookie and ensures the user has one of the allowed roles. +func RequireAuth(allowedRoles ...string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + // Helper to handle unauthorized/forbidden gracefully depending on endpoint type + handleAuthError := func(w http.ResponseWriter, r *http.Request, statusCode int, msg string) { + // Check if it's an API request (starts with /v1/ or /api/) + if strings.HasPrefix(r.URL.Path, "/v1/") || strings.HasPrefix(r.URL.Path, "/api/") { + jsonwrite.WriteJSON(w, statusCode, jsonwrite.APIResponse{ + Success: false, + Message: msg, + }) + } else { + // It's a view, redirect to login + http.Redirect(w, r, "/login", http.StatusSeeOther) + } + } + + var claims *authentication.JWTClaims + var tokenValid bool + var tokenString string + + // Extract Access Token from Header OR Cookie + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + // Extract from Authorization header (API / Thunder Client / cURL) + tokenString = strings.TrimPrefix(authHeader, "Bearer ") + } else { + // Fallback: Check if token is in the Cookie (Web Browsers) + cookie, err := r.Cookie("jwt") + if err == nil { + tokenString = cookie.Value + } + } + + // If we found a token in either place, validate it + if tokenString != "" { + parsedClaims, err := authentication.ValidateJWT(tokenString) + if err == nil && parsedClaims.Subject == "access" { + claims = parsedClaims + tokenValid = true + } else if err != nil { + log.Printf("DEBUG: JWT Validation failed: %v", err) + } + } + + // 2. If Access Token is missing or invalid, try Silent Refresh + if !tokenValid { + refreshCookie, err := r.Cookie("refresh_token") + if err == nil { + refreshClaims, err := authentication.ValidateJWT(refreshCookie.Value) + if err == nil && refreshClaims.Subject == "refresh" { + // Valid refresh token! Generate new tokens. + newAccess, newRefresh, err := authentication.GenerateTokens(refreshClaims.UserID, refreshClaims.Role) + if err == nil { + // Set new cookies + http.SetCookie(w, &http.Cookie{ + Name: "jwt", + Value: newAccess, + Expires: time.Now().Add(15 * time.Minute), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + http.SetCookie(w, &http.Cookie{ + Name: "refresh_token", + Value: newRefresh, + Expires: time.Now().Add(7 * 24 * time.Hour), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + + // Treat request as valid using the refresh claims + claims = refreshClaims + tokenValid = true + } + } + } + } + + if !tokenValid { + handleAuthError(w, r, http.StatusUnauthorized, "Unauthorized: Missing or invalid authentication token") + return + } + + // 3. Check role-based access control (RBAC) + roleAllowed := false + if len(allowedRoles) == 0 { + // If no specific roles are required, any valid token is sufficient + roleAllowed = true + } else { + for _, role := range allowedRoles { + if claims.Role == role { + roleAllowed = true + break + } + } + } + + if !roleAllowed { + handleAuthError(w, r, http.StatusForbidden, "Forbidden: Insufficient privileges") + return + } + + // Pass the claims to the next handler via request context + ctx := context.WithValue(r.Context(), UserClaimsKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} \ No newline at end of file diff --git a/backend/internal/middleware/rateLimit.go b/backend/internal/middleware/rateLimit.go index 15f993f..54f1dce 100644 --- a/backend/internal/middleware/rateLimit.go +++ b/backend/internal/middleware/rateLimit.go @@ -1,4 +1,4 @@ -package authentication +package middleware import ( "encoding/json" diff --git a/backend/internal/pkg/smtpbody/smtp.go b/backend/internal/pkg/smtpbody/smtp.go index caec117..668f5d1 100644 --- a/backend/internal/pkg/smtpbody/smtp.go +++ b/backend/internal/pkg/smtpbody/smtp.go @@ -285,6 +285,45 @@ func EmailVerificationBody() string {
+