diff --git a/backend/cmd/app/main.go b/backend/cmd/app/main.go index ac0f1a6..0570e1b 100644 --- a/backend/cmd/app/main.go +++ b/backend/cmd/app/main.go @@ -91,13 +91,13 @@ func main() { mux.HandleFunc("GET /u/{username}/dashboard", userHandler.DashboardView) mux.HandleFunc("GET /u/{username}/card", userHandler.CardView) mux.HandleFunc("GET /u/{username}/topup", userHandler.TopUpView) - // Your frontend calls this to get the Stripe URL - mux.HandleFunc("POST /api/topup/create-session/{username}", userHandler.CreateStripeCheckoutSession) + // Your frontend calls this to get the Xendit URL + mux.HandleFunc("POST /api/topup/create-session/{username}", userHandler.CreateXenditInvoice) // Payment gateway endpoints - // STRIPE'S servers call this behind the scenes when the payment is done - mux.HandleFunc("POST /api/webhooks/stripe", userHandler.StripeWebhook) - mux.HandleFunc("POST /v1/user/{username}/topup/checkout", userHandler.CreateStripeCheckoutSession) // + // 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) diff --git a/backend/internal/user/customer_topup.go b/backend/internal/user/customer_topup.go index 8d642d3..ee1f941 100644 --- a/backend/internal/user/customer_topup.go +++ b/backend/internal/user/customer_topup.go @@ -10,15 +10,20 @@ import ( "time" jsonwrite "unicard-go/backend/internal/pkg/handler" - "github.com/stripe/stripe-go/v85" - "github.com/stripe/stripe-go/v85/checkout/session" + "github.com/xendit/xendit-go" + "github.com/xendit/xendit-go/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"` } +// struct for topup record, for saving in db and for webhook callback processing +// note the fields that match the db schema +// 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"` @@ -28,9 +33,11 @@ type TopUpRecord struct { PaymentMethod string `json:"payment_method" db:"payment_method"` } +// TopUpView displays the top-up page for a user func (h *Handler) TopUpView(w http.ResponseWriter, r *http.Request) { fmt.Println("TopUp view is running...") + // get username from url parameter username := r.PathValue("username") data := struct { Username string @@ -41,9 +48,13 @@ func (h *Handler) TopUpView(w http.ResponseWriter, r *http.Request) { h.Tpl.ExecuteTemplate(w, "customer_topup.html", data) } -func (h *Handler) CreateStripeCheckoutSession(w http.ResponseWriter, r *http.Request) { +// create xendit invoice - Payment Methods Options +// CREDIT_CARD, QR_CODE, EWALLET +func (h *Handler) CreateXenditInvoice(w http.ResponseWriter, r *http.Request) { + // get username from url parameter username := r.PathValue("username") + // get request body var req TopUpRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ @@ -52,6 +63,8 @@ func (h *Handler) CreateStripeCheckoutSession(w http.ResponseWriter, r *http.Req }) return } + + // check if amount is at least 50 pesos if req.Amount < 50 { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ Success: false, @@ -70,6 +83,8 @@ func (h *Handler) CreateStripeCheckoutSession(w http.ResponseWriter, r *http.Req `, username).Scan(&cardNumber, &email) if err != nil { + // print error and return + log.Println("Failed to find card for user:", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ Success: false, Message: "Failed to find card for user", @@ -77,59 +92,43 @@ func (h *Handler) CreateStripeCheckoutSession(w http.ResponseWriter, r *http.Req return } - // Stripe takes amount in cents/lowest denomination. For PHP, it's centavos. - topupCentavos := int64(req.Amount * 100) // ex: 100 -> 10000 - feeCentavos := int64(15 * 100) // 15 pesos for fee + // 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") // Fallback if domain is malformed if domain == "http://" { - domain = "http://localhost:3000" + domain = "http://127.0.0.1:3000" } - stripe.Key = os.Getenv("STRIPE_SECRET_KEY") - params := &stripe.CheckoutSessionParams{ - PaymentMethodTypes: stripe.StringSlice([]string{ - "card", - }), - Metadata: map[string]string{ - "card_number": cardNumber, // Use securely fetched card number from DB! - "base_amount": fmt.Sprintf("%.2f", req.Amount), - "convenience_fee": "15.00", - }, - LineItems: []*stripe.CheckoutSessionLineItemParams{ - { - PriceData: &stripe.CheckoutSessionLineItemPriceDataParams{ - Currency: stripe.String("php"), - ProductData: &stripe.CheckoutSessionLineItemPriceDataProductDataParams{ - Name: stripe.String("Unicard Top-Up"), - Description: stripe.String("Top up your unicard with stripe payment"), - }, - UnitAmount: stripe.Int64(topupCentavos), - }, - Quantity: stripe.Int64(1), - }, - { - PriceData: &stripe.CheckoutSessionLineItemPriceDataParams{ - Currency: stripe.String("php"), - ProductData: &stripe.CheckoutSessionLineItemPriceDataProductDataParams{ - Name: stripe.String("Convienence Fee"), - Description: stripe.String("15 peso convienence fee"), - }, - UnitAmount: stripe.Int64(feeCentavos), - }, - Quantity: stripe.Int64(1), - }, - }, - Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), - SuccessURL: stripe.String(domain + "/u/" + username + "/dashboard"), - CancelURL: stripe.String(domain + "/u/" + username + "/topup"), + + // set xendit secret key + xendit.Opt.SecretKey = os.Getenv("XENDIT_SECRET_KEY") + + // Encode metadata into ExternalID since Xendit v1 doesn't have metadata field on CreateParams + // Format: TOPUP{CardNumber}{TopupAmount}{FeeAmount}{Timestamp} + externalID := fmt.Sprintf("TOPUP%s%.2f%.2f%d", cardNumber, topupAmount, feeAmount, time.Now().UnixNano()) + + // 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", "EWALLET", "QR_CODE"}, + Currency: "PHP", } // creating the checkout session - s, err := session.New(params) + resp, xErr := invoice.Create(&data) // Handle error if session creation fails - if err != nil { - log.Println("Failed to create checkout session:", err) + if xErr != nil { + log.Println("Failed to create checkout session:", xErr) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ Success: false, Message: "Failed to create checkout session", @@ -138,13 +137,15 @@ func (h *Handler) CreateStripeCheckoutSession(w http.ResponseWriter, r *http.Req } // Handle success case + // return the checkout session url to the frontend jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ Success: true, Message: "Checkout session created successfully", - Data: map[string]string{"url": s.URL}, + Data: map[string]string{"url": resp.InvoiceURL}, }) - log.Println("Checkout session created successfully:", s.URL) + // log the checkout session url + log.Println("Checkout session created successfully:", resp.InvoiceURL) } // save topup tp database @@ -202,7 +203,7 @@ func (h *Handler) SaveTopUpToDatabase(w http.ResponseWriter, r *http.Request) { // Insert into Spending Ledger (transactions table) // *Note: Adjust 'category' if your enum doesn't include 'top_up' queryTx := `INSERT INTO transactions (transaction_id, card_number, merchant_id, terminal_id, transaction_type, amount, service_fee, processed_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - // "Stripe" as the merchant_id since this is an internal load, not a retail/Fare purchase + // "xendit" as the merchant_id since this is an internal load, not a retail/Fare purchase if _, err := tx.Exec(queryTx, transactionID, req.CardNumber, sql.NullString{}, sql.NullString{}, "topup", req.Amount, req.ConvenienceFee, sql.NullString{}); err != nil { log.Println("Failed to record global transaction:", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ diff --git a/backend/internal/user/dashboard.go b/backend/internal/user/dashboard.go index 5fe8f6f..3298fd8 100644 --- a/backend/internal/user/dashboard.go +++ b/backend/internal/user/dashboard.go @@ -17,6 +17,7 @@ type Transaction struct { 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"` } // DashboardUser info struct for the user dashboard view @@ -50,10 +51,9 @@ func (h *Handler) DashboardView(w http.ResponseWriter, r *http.Request) { h.Tpl.ExecuteTemplate(w, "dashboard.html", data) } - func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) { fmt.Println("Dashboard JSON handler is running...") - + // Get user ID from path param (No cookies for now) userID := r.PathValue("username") if userID == "" { @@ -136,39 +136,43 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) { // Fetch recent transactions txnQuery := ` - SELECT t.transaction_id, t.created_at, m.business_name, t.transaction_type, t.amount, t.terminal_id, t.processed_by - 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.user_id - WHERE u.username = ? - ORDER BY t.created_at DESC LIMIT 5 - ` - rows, err := h.DB.Query(txnQuery, userID) - var transactions []Transaction - if err == nil { - defer rows.Close() - for rows.Next() { - var t Transaction - var createdAt string - var businessName sql.NullString - var processedBy sql.NullString - if err := rows.Scan(&t.TransactionID, &createdAt, &businessName, &t.Type, &t.Amount, &t.TerminalID, &processedBy); err == nil { - t.Date = formatDate(createdAt) - t.Time = formatTime(createdAt) - if businessName.Valid { - t.Description = businessName.String - } else if processedBy.Valid && processedBy.String == "stripe" { - t.Description = "Stripe Top-Up" - } else { - t.Description = "Terminal Simulation" - } - transactions = append(transactions, t) - } - } - } else { - fmt.Printf("Error fetching transactions: %v\n", err) - } + SELECT t.transaction_id, t.description, t.created_at, t.transaction_type, t.amount, t.terminal_id, t.status + FROM transactions t + JOIN cards c ON t.card_number = c.card_number + JOIN users u ON c.user_id = u.user_id + WHERE u.username = ? + ORDER BY t.created_at DESC LIMIT 5 +` +rows, err := h.DB.Query(txnQuery, userID) +var transactions []Transaction +if err != nil { + fmt.Printf("Error fetching transactions: %v\n", err) +} else { + defer rows.Close() + for rows.Next() { + var t Transaction + var createdAt string + var description sql.NullString + if err := rows.Scan( + &t.TransactionID, + &description, + &createdAt, + &t.Type, + &t.Amount, + &t.TerminalID, + &t.Status, + ); err != nil { + fmt.Printf("Error scanning transaction row: %v\n", err) + continue + } + t.Date = formatDate(createdAt) + t.Time = formatTime(createdAt) + if description.Valid { + t.Description = description.String + } + transactions = append(transactions, t) + } +} dashboardUser := DashboardUser{ ID: id, diff --git a/backend/internal/user/stripe_webhook.go b/backend/internal/user/stripe_webhook.go deleted file mode 100644 index 82f98a2..0000000 --- a/backend/internal/user/stripe_webhook.go +++ /dev/null @@ -1,105 +0,0 @@ -package user - -import ( - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "strconv" - "time" - - "github.com/stripe/stripe-go/v85" - "github.com/stripe/stripe-go/v85/webhook" -) - -// StripeWebhook is the endpoint Stripe will POST to when a payment succeeds -func (h *Handler) StripeWebhook(w http.ResponseWriter, r *http.Request) { - const MaxBodyBytes = int64(65536) - r.Body = http.MaxBytesReader(w, r.Body, MaxBodyBytes) - - payload, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "Error reading request body", http.StatusServiceUnavailable) - return - } - - // 1. Verify the request actually came from Stripe - endpointSecret := os.Getenv("STRIPE_WEBHOOK_SECRET") // Get this from your Stripe Dashboard - signatureHeader := r.Header.Get("Stripe-Signature") - - event, err := webhook.ConstructEventWithOptions(payload, signatureHeader, endpointSecret, webhook.ConstructEventOptions{ - IgnoreAPIVersionMismatch: true, - }) - if err != nil { - log.Println("Webhook signature verification failed:", err) - http.Error(w, "Bad signature", http.StatusBadRequest) - return - } - - // 2. Only process successful checkout sessions - if event.Type == "checkout.session.completed" { - var session stripe.CheckoutSession - err := json.Unmarshal(event.Data.Raw, &session) - if err != nil { - log.Println("Error parsing webhook JSON:", err) - http.Error(w, "Error parsing webhook JSON", http.StatusBadRequest) - return - } - - // 3. Extract the hidden data we passed earlier - cardNumber := session.Metadata["card_number"] - amountStr := session.Metadata["base_amount"] - feeStr := session.Metadata["convenience_fee"] - - baseAmount, _ := strconv.ParseFloat(amountStr, 64) - convenienceFee, _ := strconv.ParseFloat(feeStr, 64) - - // 4. Safely write to the database - err = h.processSuccessfulTopUp(cardNumber, baseAmount, convenienceFee) - if err != nil { - log.Println("Database transaction failed:", err) - http.Error(w, "Database error", http.StatusInternalServerError) - return - } - - log.Printf("Successfully loaded ₱%.2f onto card %s", baseAmount, cardNumber) - } - - // Stripe expects a 200 OK immediately to know we received the event - w.WriteHeader(http.StatusOK) -} - -// Helper function to handle the database transaction (This replaces your old HTTP handler) -func (h *Handler) processSuccessfulTopUp(cardNumber string, baseAmount float64, convenienceFee float64) error { - topupID := fmt.Sprintf("TOPUP-%d", time.Now().UnixNano()) - transactionID := fmt.Sprintf("TX-%d", time.Now().UnixNano()) - - tx, err := h.DB.Begin() - if err != nil { - return err - } - defer tx.Rollback() - - // Update User Balance - if _, err := tx.Exec(`UPDATE cards SET balance = balance + ? WHERE card_number = ?`, baseAmount, cardNumber); err != nil { - return err - } - - // Insert into Loading Ledger (top_ups table) - // NOTE: handled_by is nil because this is automated - queryTopUp := `INSERT INTO top_ups (topup_id, card_number, amount, convenience_fee, payment_method) VALUES (?, ?, ?, ?, ?)` - if _, err := tx.Exec(queryTopUp, topupID, cardNumber, baseAmount, convenienceFee, "stripe"); err != nil { - return err - } - - // Insert into Spending Ledger (transactions table) - // NOTE: Ensure "SYS_STRIPE", "SYS_TERM" and "SYS_BOT" exist in your merchants, terminals and users tables to prevent Foreign Key crashes! - queryTx := `INSERT INTO transactions (transaction_id, card_number, transaction_type, amount, service_fee) VALUES (?, ?, ?, ?, ?)` - if _, err := tx.Exec(queryTx, transactionID, cardNumber, "topup", baseAmount, convenienceFee); err != nil { - return err - } - - return tx.Commit() -} diff --git a/backend/internal/user/transaction.go b/backend/internal/user/transaction.go index c41a42e..8b9ea46 100644 --- a/backend/internal/user/transaction.go +++ b/backend/internal/user/transaction.go @@ -36,7 +36,7 @@ func (h *Handler) TransactionsJSONHandler(w http.ResponseWriter, r *http.Request // Fetch transactions txnQuery := ` - SELECT t.transaction_id, t.created_at, m.business_name, t.transaction_type, t.amount, t.terminal_id + SELECT t.transaction_id, t.created_at, m.business_name, t.transaction_type, t.amount, t.terminal_id, t.description, t.status FROM transactions t JOIN cards c ON t.card_number = c.card_number JOIN users u ON c.user_id = u.user_id @@ -65,8 +65,16 @@ func (h *Handler) TransactionsJSONHandler(w http.ResponseWriter, r *http.Request var createdAt string var businessName sql.NullString var terminalId sql.NullString - if err := rows.Scan(&t.TransactionID, &createdAt, &businessName, &t.Type, &t.Amount, &terminalId); err == nil { - t.Status = "Completed" + var dbDescription sql.NullString + var dbStatus sql.NullString + if err := rows.Scan(&t.TransactionID, &createdAt, &businessName, &t.Type, &t.Amount, &terminalId, &dbDescription, &dbStatus); 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 @@ -76,7 +84,9 @@ func (h *Handler) TransactionsJSONHandler(w http.ResponseWriter, r *http.Request t.TerminalID = "N/A" } - if businessName.Valid { + 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" diff --git a/backend/internal/user/xendit_webhook.go b/backend/internal/user/xendit_webhook.go new file mode 100644 index 0000000..a4cc81a --- /dev/null +++ b/backend/internal/user/xendit_webhook.go @@ -0,0 +1,182 @@ +package user + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +// 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"` +} + +// XenditWebhook handles incoming webhook notifications from Xendit for invoice payments. +// It validates the callback token, processes the payment payload, and updates the user's balance. +// more info here: https://developers.xendit.co/docs/invoices#handling-invoice-completion-via-webhooks +func (h *Handler) XenditWebhook(w http.ResponseWriter, r *http.Request) { + // Verify Xendit Callback Token + xenditToken := os.Getenv("XENDIT_WEBHOOK_KEY") + callbackToken := r.Header.Get("x-callback-token") + + // If no token is configured, skip validation for development, but it's recommended to have it + if xenditToken != "" && callbackToken != xenditToken { + log.Println("Invalid x-callback-token") + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // read body from xendit invoice webhook + body, err := io.ReadAll(r.Body) + if err != nil { + log.Println("Failed to read body") + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // parse body + var payload XenditWebhookPayload + if err := json.Unmarshal(body, &payload); err != nil { + log.Println("Failed to parse webhook JSON:", err) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // switch case for payment status + switch payload.Status { + case "PAID", "SETTLED": + externalID := payload.ExternalID + // check if external id is for top up + if strings.HasPrefix(externalID, "TOPUP") && len(externalID) > 21 { + // TOPUP is 5 chars, CardNumber is strictly 16 chars + // indexes 5 to 21 + cardNumber := externalID[5:21] + + remainder := externalID[21:] + // find first dot + firstDot := strings.Index(remainder, ".") + if firstDot == -1 || len(remainder) < firstDot+3 { + log.Println("Invalid external ID format: missing base amount") + http.Error(w, "Invalid external ID data", http.StatusBadRequest) + return + } + baseAmountStr := remainder[:firstDot+3] // e.g. 50.00 + + remainder2 := remainder[firstDot+3:] + // find second dot + secondDot := strings.Index(remainder2, ".") + if secondDot == -1 || len(remainder2) < secondDot+3 { + log.Println("Invalid external ID format: missing fee amount") + http.Error(w, "Invalid external ID data", http.StatusBadRequest) + return + } + convenienceFeeStr := remainder2[:secondDot+3] // e.g. 15.00 + + // convert string to float + baseAmount, err1 := strconv.ParseFloat(baseAmountStr, 64) + convenienceFee, err2 := strconv.ParseFloat(convenienceFeeStr, 64) + + if err1 != nil || err2 != nil { + log.Println("Failed to parse amounts from external ID") + http.Error(w, "Invalid external ID data", http.StatusBadRequest) + return + } + + // process successful top up + err := h.processSuccessfulTopUp(cardNumber, baseAmount, convenienceFee, 0.0, "xendit", payload.ExternalID) + if err != nil { + log.Println("Database transaction failed:", err) + w.WriteHeader(http.StatusOK) // don't return 500 to Xendit + return + } + + log.Printf("Successfully loaded ₱%.2f onto card %s via Xendit", baseAmount, cardNumber) + } else { + log.Println("Unrecognized External ID format:", payload.ExternalID) + } + // log if payment failed or expired + case "EXPIRED", "FAILED": + log.Printf("Payment failed for external ID: %s, status: %s", payload.ExternalID, payload.Status) + } + + // Always 200 OK to Xendit + w.WriteHeader(http.StatusOK) +} + +// Helper function to handle the database transaction +func (h *Handler) processSuccessfulTopUp(cardNumber string, baseAmount float64, convenienceFee float64, paymentGatewayCost float64, paymentMethod string, externalID string) error { + topupID := fmt.Sprintf("TOPUP-%d", time.Now().UnixNano()) + transactionID := fmt.Sprintf("TX-%d", time.Now().UnixNano()) + + // begin transaction to make sure that all database operations are performed in a single unit of work + // if any operation fails, the entire transaction will be rolled back and the database will be left unchanged + tx, err := h.DB.Begin() + if err != nil { + return err + } + // if transaction fails, rollback not close the transaction + // Rollback() will do nothing if the transaction is already closed (committed or rolled back) + // so that's why we use defer tx.Rollback() + // This pattern ensures that the database connection is properly managed + // and that any errors during the transaction are handled gracefully. + defer tx.Rollback() + + // Check if already processed + // this prevents double top up if xendit sends multiple webhooks or network delay causes multiple requests + // using external_id as unique identifier for each top up + var exists bool + if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM top_ups WHERE external_id = ?)`, externalID).Scan(&exists); err != nil { + return err + } + if exists { + return nil // already processed, skip silently + } + + // Update User Balance + // Using atomic update to prevent race conditions + if _, err := tx.Exec(`UPDATE cards SET balance = balance + ? WHERE card_number = ?`, baseAmount, cardNumber); err != nil { + return err + } + + // Insert into Loading Ledger (top_ups table) + // top_ups table is our loading ledger + queryTopUp := `INSERT INTO top_ups (topup_id, card_number, amount, convenience_fee, gateway_cost, payment_method, handled_by, external_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + if _, err := tx.Exec(queryTopUp, topupID, cardNumber, baseAmount, convenienceFee, paymentGatewayCost, paymentMethod, "payment gateway", externalID); err != nil { + return err + } + + // Insert into Spending Ledger (transactions table) + // transactions table is our spending ledger + queryTx := `INSERT INTO transactions (transaction_id, card_number, merchant_id, terminal_id, transaction_type, amount, service_fee, processed_by, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + if _, err := tx.Exec(queryTx, transactionID, cardNumber, "xendit", "xendit", "topup", baseAmount, convenienceFee, "xendit", "Successful topup via Xendit"); err != nil { + return err + } + + // Commit the transaction if everything is fine + return tx.Commit() +} diff --git a/frontend/assets/js/customer_topup.js b/frontend/assets/js/customer_topup.js index 826e767..aee6794 100644 --- a/frontend/assets/js/customer_topup.js +++ b/frontend/assets/js/customer_topup.js @@ -68,7 +68,7 @@ document.addEventListener("DOMContentLoaded", function () { const amount = parseFloat(amountText); const method = document.querySelector('input[name="payment_method"]:checked'); - if (!method || method.value !== 'stripe' || isNaN(amount) || amount < 50) { + if (!method || method.value !== 'xendit' || isNaN(amount) || amount < 50) { return; } @@ -105,8 +105,8 @@ document.addEventListener("DOMContentLoaded", function () { } const data = await response.json(); - if (data.url) { - window.location.href = data.url; + if (data.data && data.data.url) { + window.location.href = data.data.url; } else { throw new Error('No checkout URL returned'); } diff --git a/frontend/assets/js/dashboard.js b/frontend/assets/js/dashboard.js index e348c2c..a22384a 100644 --- a/frontend/assets/js/dashboard.js +++ b/frontend/assets/js/dashboard.js @@ -179,7 +179,7 @@ document.addEventListener("DOMContentLoaded", function () { const balanceEl = document.getElementById("user-balance"); const loyaltyPointsEl = document.getElementById("user-loyalty-points"); const accountTypeEl = document.getElementById("user-account-type"); - const transactionsBody = document.getElementById("transactions-table-body"); + const transactionsBody = document.getElementById("recent-transactions-table-body"); const cardNoEl = document.getElementById("user-card-number"); const cardHolderEl = document.getElementById("user-card-holder"); @@ -287,7 +287,7 @@ document.addEventListener("DOMContentLoaded", function () { }) .catch(error => { console.error("Error loading dashboard data:", error); - const transactionsBody = document.getElementById("transactions-table-body"); + const transactionsBody = document.getElementById("recent-transactions-table-body"); if (transactionsBody) { transactionsBody.innerHTML = `