diff --git a/api/booking/register.controllers.go b/api/booking/register.controllers.go index 1b1218a2..0d94a3ab 100644 --- a/api/booking/register.controllers.go +++ b/api/booking/register.controllers.go @@ -15,6 +15,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) func BookEventCsrf(c *gin.Context) { @@ -356,14 +357,15 @@ func BookEvent(c *gin.Context) { totalFee, ) bookingID, err := q.CreateBooking(ctx, tx, db.CreateBookingParams{ - EventID: eventId, - StudentID: leaderId, - TxnID: txnId, - RegistrationFee: totalFee, - TxnStatus: models.PaymentPending, - ProductInfo: prodInfo, - SeatsReleased: 1, - Metadata: metadataJson, // TODO: I need to set it as default data of the jsonb if not present + EventID: eventId, + StudentID: leaderId, + TxnID: txnId, + RegistrationFee: totalFee, + TxnStatus: models.PaymentPending, + ProductInfo: prodInfo, + SeatsReleased: 1, + RegistrationFeeWithoutGst: pgtype.Int4{Int32: event.Price, Valid: true}, + Metadata: metadataJson, // TODO: I need to set it as default data of the jsonb if not present }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ diff --git a/api/booking/routes.go b/api/booking/routes.go index 7c358594..86d8b455 100644 --- a/api/booking/routes.go +++ b/api/booking/routes.go @@ -11,7 +11,8 @@ func BookingRoutes(r *gin.RouterGroup) { // Booking endpoints r.POST("/:eventId/book", mw.VerifyCsrf, mw.Auth, BookEvent) - r.POST("/verify", mw.Auth, VerifyTransaction) + r.POST("/verify", mw.Auth, VerifyTransactionNew) + r.POST("/reverify", ReverifyFailedTransaction) // Fetch transactions r.GET("/transactions", mw.Auth, mw.CheckAdmin, FetchAdminTransactions) diff --git a/api/booking/verify_controller_new.go b/api/booking/verify_controller_new.go new file mode 100644 index 00000000..44a98aed --- /dev/null +++ b/api/booking/verify_controller_new.go @@ -0,0 +1,770 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/Thanus-Kumaar/anokha-2025-backend/cmd" + db "github.com/Thanus-Kumaar/anokha-2025-backend/db/gen" + "github.com/Thanus-Kumaar/anokha-2025-backend/mail" + messagequeue "github.com/Thanus-Kumaar/anokha-2025-backend/message-queue" + "github.com/Thanus-Kumaar/anokha-2025-backend/models" + "github.com/Thanus-Kumaar/anokha-2025-backend/pkg" + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5" +) + +func VerifyTransactionNew(c *gin.Context) { + req, ok := pkg.ValidateRequest[models.VerifyTransactionRequest](c) + if !ok { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tx, err := cmd.DBPool.Begin(ctx) + if pkg.HandleDbTxnErr(c, err, "VERIFY") { + return + } + defer pkg.RollbackTx(c, tx, ctx, "VERIFY") + + q := db.New() + + email, err := q.GetEmailByTxnId(ctx, tx, req.TxnID) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to get email by txn id", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + + booking, err := q.GetBookingByTxnID(ctx, tx, req.TxnID) + if err == pgx.ErrNoRows { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "Transaction not found", + }) + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Requested transaction id is not found", err) + return + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to fetch booking", err) + return + } + + // If booking was already verified earlier, return current status + if booking.TxnStatus != models.PaymentPending { + c.JSON(http.StatusOK, gin.H{ + "message": "Already verified", + "status": booking.TxnStatus, + }) + pkg.Log.SuccessCtx(c) + return + } + + // TODO: Call the PayU verify API here. + // Possible values: + // "success" + // "failure" + // "pending" + // "not_found" + formBody := pkg.BuildVerifyPayUForm(req.TxnID) + httpReq, err := http.NewRequest( + "POST", + cmd.Env.PayUVerifyURL, + strings.NewReader(formBody), + ) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to build PayU request", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to contact PayU", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Payment Gateway Failed. Try again later", // Different msg + }) + return + } + defer func() { + if err := resp.Body.Close(); err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to close body", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + }() + + // Parsing the response + var payURes models.PayUVerifyResponse + if err := json.NewDecoder(resp.Body).Decode(&payURes); err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to decode PayU verify response", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Payment Gateway Failed. Try again later", + }) + return + } + + gatewayStatus := models.MapPayUStatus(payURes, req.TxnID) + + event, err := q.GetEventByIdQuery(ctx, tx, booking.EventID) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to get event details", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + + if gatewayStatus == models.PaymentFailed || gatewayStatus == models.PaymentNotFound { + // Restoring the seats + err = q.UpdateEventSeats(ctx, tx, db.UpdateEventSeatsParams{ + SeatsFilled: -booking.SeatsReleased, + ID: booking.EventID, + }) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to update event seats", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + + // // If team event, then remove the team details + // if event.IsGroup { + // teamID, err := q.GetTeamIDByBooking(ctx, tx, booking.ID) + // if err != nil && err != sql.ErrNoRows { + // pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to get team ID", err) + // c.JSON(http.StatusInternalServerError, gin.H{ + // "message": "Oops! Something happened. Please try again later", + // }) + // return + // } + // if err == nil { + // // delete members first due to foreign key relation + // if err := q.DeleteTeamDetailsOfTeam(ctx, tx, teamID); err != nil { + // pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to delete team members", err) + // c.JSON(http.StatusInternalServerError, gin.H{ + // "message": "Oops! Something happened. Please try again later", + // }) + // return + // } + // // delete team + // if err := q.DeleteTeam(ctx, tx, booking.ID); err != nil { + // pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to delete team", err) + // c.JSON(http.StatusInternalServerError, gin.H{ + // "message": "Oops! Something happened. Please try again later", + // }) + // return + // } + // } + // } + + // Update booking status → failed + err = q.UpdateBookingStatus(ctx, tx, db.UpdateBookingStatusParams{ + TxnStatus: models.PaymentFailed, + ID: booking.ID, + }) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to update booking status", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + // failure case + err = tx.Commit(ctx) + if pkg.HandleDbTxnCommitErr(c, err, "VERIFY") { + return + } + + pkg.Log.SuccessCtx(c) + c.JSON(http.StatusOK, gin.H{ + "message": "Payment failed", + "status": models.PaymentFailed, + }) + return + } + if gatewayStatus == models.PaymentSuccess { + // Getting the schedule ids of the selected event + schedules, err := q.GetSchedulesByEventID(ctx, tx, event.ID) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to get schedules in success", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + + // Getting details of the student - needed for mail and attendance + student, err := q.GetStudentByEmail(ctx, tx, email) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to retrive student data", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + } + + if !event.IsGroup { + for _, s := range schedules { + _, err := q.CreateSoloEventParticipant(ctx, tx, db.CreateSoloEventParticipantParams{ + StudentID: booking.StudentID, + EventID: booking.EventID, + EventScheduleID: s, + BookingID: booking.ID, + StudentName: student.Name, + StudentEmail: student.Email, + }) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to insert in solo participant", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + } + } else { + teamId, err := q.GetTeamIDByBooking(ctx, tx, booking.ID) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to get team details in verify", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + members, err := q.GetTeamMembersByTeamID(ctx, tx, teamId) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to fetch team members.", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + // Inserting into team attendance table + for _, s := range schedules { + for _, m := range members { + _, err := q.CreateTeamAttendance(ctx, tx, db.CreateTeamAttendanceParams{ + StudentID: m.StudentID, + EventScheduleID: s, + }) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to insert into team attd.", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + } + } + + } + + err = q.UpdateBookingStatus(ctx, tx, db.UpdateBookingStatusParams{ + TxnStatus: models.PaymentSuccess, + ID: booking.ID, + }) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to update booking status", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + + // success case + err = tx.Commit(ctx) + if pkg.HandleDbTxnCommitErr(c, err, "VERIFY") { + return + } + + // If there is metadata, read and publish + if len(booking.Metadata) > 0 { + var metadataMap map[string]any + if err := json.Unmarshal(booking.Metadata, &metadataMap); err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to unmarshal booking metadata", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + + // Hackathon payload + if raw, ok := metadataMap["hackathon_payload"]; ok { + payloadStr, ok := raw.(string) + if !ok { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: hackathon_payload is not string", nil) + return + } + + if err := messagequeue.Rabbit.Publish( + ctx, + messagequeue.QueueHackathonRegistrations, + []byte(payloadStr), + ); err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to publish hackathon payload", err) + return + } + // WOC payload + } else if raw, ok := metadataMap["woc_payload"]; ok { + payloadStr, ok := raw.(string) + if !ok { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: woc_payload is not string", nil) + return + } + + if err := messagequeue.Rabbit.Publish( + ctx, + messagequeue.QueueWocRegistrations, + []byte(payloadStr), + ); err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to publish WOC payload", err) + return + } + } + } + + var completeSchedules []models.EventScheduleInput + schedulesBytes, _ := json.Marshal(event.Schedules) + _ = json.Unmarshal(schedulesBytes, &completeSchedules) + + var selected models.EventScheduleInput + if len(completeSchedules) > 0 { + selected = completeSchedules[0] + for _, s := range completeSchedules { + d1, _ := time.Parse("2006-01-02", s.EventDate) + d2, _ := time.Parse("2006-01-02", selected.EventDate) + if d1.Before(d2) { + selected = s + } + } + } + + err = mail.Mail.Enqueue(&mail.EmailRequest{ + To: []string{}, + Subject: "Event Registration - Anokha 2026", + Type: "event-reg", + Data: &mail.RegistrationData{ + UserName: student.Name, + EventName: event.EventName, + EventDate: selected.EventDate, + EventTime: selected.StartTime + " - " + selected.EndTime, + EventLocation: selected.Venue, + }, + Retries: mail.MaxRetryCount, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + pkg.Log.ErrorCtx(c, "[MAIL-ERROR]: Failed to add request to email queue", err) + return + } + pkg.Log.SuccessCtx(c) + c.JSON(http.StatusOK, gin.H{ + "message": "Payment verified successfully", + "status": models.PaymentSuccess, + }) + return + } + + // Still pending case will be handled here + + // Warning so that filtering the pending trasactions from logs will be easier + pkg.Log.WarnCtx(c, "[VERIFY-WARN]: Verification successful, but status still pending for "+req.TxnID) + c.JSON(http.StatusOK, gin.H{ + "message": "Payment still pending", + "status": models.PaymentPending, + }) +} + +func ReverifyFailedTransaction(c *gin.Context) { + req, ok := pkg.ValidateRequest[models.ReverifyTransactionRequest](c) + if !ok { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tx, err := cmd.DBPool.Begin(ctx) + if pkg.HandleDbTxnErr(c, err, "REVERIFY") { + return + } + defer pkg.RollbackTx(c, tx, ctx, "REVERIFY") + + q := db.New() + + email, err := q.GetEmailByTxnId(ctx, tx, req.TxnID) + if err != nil { + pkg.Log.ErrorCtx(c, "[REVERIFY-ERROR]: Failed to get email by txn id", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + + booking, err := q.GetBookingByTxnID(ctx, tx, req.TxnID) + if err == pgx.ErrNoRows { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "Transaction not found", + }) + pkg.Log.ErrorCtx(c, "[REVERIFY-ERROR]: Requested transaction id is not found", err) + return + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + pkg.Log.ErrorCtx(c, "[REVERIFY-ERROR]: Failed to fetch booking", err) + return + } + + // Only reverifying failed transactions + if booking.TxnStatus == models.PaymentFailed { + + // Calling PayU verify API here. + formBody := pkg.BuildVerifyPayUForm(req.TxnID) + httpReq, err := http.NewRequest( + "POST", + cmd.Env.PayUVerifyURL, + strings.NewReader(formBody), + ) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to build PayU request", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to contact PayU", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Payment Gateway Failed. Try again later", // Different msg + }) + return + } + defer func() { + if err := resp.Body.Close(); err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to close body", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + }() + + // Parsing the response + var payURes models.PayUVerifyResponse + if err := json.NewDecoder(resp.Body).Decode(&payURes); err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to decode PayU verify response", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Payment Gateway Failed. Try again later", + }) + return + } + + gatewayStatus := models.MapPayUStatus(payURes, req.TxnID) + + event, err := q.GetEventByIdQuery(ctx, tx, booking.EventID) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to get event details", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + + // if still failed + if gatewayStatus == models.PaymentFailed || gatewayStatus == models.PaymentNotFound { + // Restore released seats and close dispute + disputeId, err := q.GetDisputeIdFromTxnIdQuery(ctx, tx, req.TxnID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + pkg.Log.ErrorCtx(c, "[DISPUTE-ERROR]: Failed to get dispute id from txn id", err) + return + } + // disputeId, ok := pkg.GrabUuid(c, disputeIdStr, "REVERIFY", "disputeId") + // if !ok { + // return + // } + + row, err := q.CloseAsFalseDisputeQuery(ctx, tx, disputeId) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + pkg.Log.ErrorCtx(c, "[DISPUTE-ERROR]: Failed to close dispute as false", err) + return + } + if row == 0 { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + pkg.Log.ErrorCtx(c, "[DISPUTE-ERROR]: No rows affected while closing dispute as false", nil) + return + } + + row, err = q.DecrementSeatFilledCountQuery(ctx, tx, booking.EventID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + pkg.Log.ErrorCtx(c, "[DISPUTE-ERROR]: Failed to decrement seat filled count", err) + return + } + if row == 0 { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + pkg.Log.ErrorCtx(c, "[DISPUTE-ERROR]: No rows affected while decrementing seat filled count", nil) + return + } + + // booking status remains failed + err = tx.Commit(ctx) + if pkg.HandleDbTxnCommitErr(c, err, "REVERIFY") { + return + } + + pkg.Log.SuccessCtx(c) + c.JSON(http.StatusOK, gin.H{ + "message": "Payment failed", + "status": models.PaymentFailed, + }) + return + } + + // if payment is success + if gatewayStatus == models.PaymentSuccess { + // Closing the dispute as valid payment + disputeId, err := q.GetDisputeIdFromTxnIdQuery(ctx, tx, req.TxnID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + pkg.Log.ErrorCtx(c, "[DISPUTE-ERROR]: Failed to get dispute id from txn id", err) + return + } + + row, err := q.CloseAsTrueDisputeQuery(ctx, tx, disputeId) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + pkg.Log.ErrorCtx(c, "[DISPUTE-ERROR]: Failed to close dispute as true", err) + return + } + if row == 0 { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + pkg.Log.ErrorCtx(c, "[DISPUTE-ERROR]: No rows affected while closing dispute as true", nil) + return + } + + // Getting the schedule ids of the selected event + schedules, err := q.GetSchedulesByEventID(ctx, tx, event.ID) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to get schedules in success", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + + // Getting details of the student - needed for mail and attendance + student, err := q.GetStudentByEmail(ctx, tx, email) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to retrive student data", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + } + + if !event.IsGroup { + for _, s := range schedules { + _, err := q.CreateSoloEventParticipant(ctx, tx, db.CreateSoloEventParticipantParams{ + StudentID: booking.StudentID, + EventID: booking.EventID, + EventScheduleID: s, + BookingID: booking.ID, + StudentName: student.Name, + StudentEmail: student.Email, + }) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to insert in solo participant", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + } + } else { + teamId, err := q.GetTeamIDByBooking(ctx, tx, booking.ID) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to get team details in verify", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + members, err := q.GetTeamMembersByTeamID(ctx, tx, teamId) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to fetch team members.", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + // Inserting into team attendance table + for _, s := range schedules { + for _, m := range members { + _, err := q.CreateTeamAttendance(ctx, tx, db.CreateTeamAttendanceParams{ + StudentID: m.StudentID, + EventScheduleID: s, + }) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to insert into team attd.", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + } + } + + } + + err = q.UpdateBookingStatus(ctx, tx, db.UpdateBookingStatusParams{ + TxnStatus: models.PaymentSuccess, + ID: booking.ID, + }) + if err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to update booking status", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + + // success case + err = tx.Commit(ctx) + if pkg.HandleDbTxnCommitErr(c, err, "VERIFY") { + return + } + + // If there is metadata, read and publish + if len(booking.Metadata) > 0 { + var metadataMap map[string]any + if err := json.Unmarshal(booking.Metadata, &metadataMap); err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to unmarshal booking metadata", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + return + } + + // Hackathon payload + if raw, ok := metadataMap["hackathon_payload"]; ok { + payloadStr, ok := raw.(string) + if !ok { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: hackathon_payload is not string", nil) + return + } + + if err := messagequeue.Rabbit.Publish( + ctx, + messagequeue.QueueHackathonRegistrations, + []byte(payloadStr), + ); err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to publish hackathon payload", err) + return + } + // WOC payload + } else if raw, ok := metadataMap["woc_payload"]; ok { + payloadStr, ok := raw.(string) + if !ok { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: woc_payload is not string", nil) + return + } + + if err := messagequeue.Rabbit.Publish( + ctx, + messagequeue.QueueWocRegistrations, + []byte(payloadStr), + ); err != nil { + pkg.Log.ErrorCtx(c, "[VERIFY-ERROR]: Failed to publish WOC payload", err) + return + } + } + } + + var completeSchedules []models.EventScheduleInput + schedulesBytes, _ := json.Marshal(event.Schedules) + _ = json.Unmarshal(schedulesBytes, &completeSchedules) + + var selected models.EventScheduleInput + if len(completeSchedules) > 0 { + selected = completeSchedules[0] + for _, s := range completeSchedules { + d1, _ := time.Parse("2006-01-02", s.EventDate) + d2, _ := time.Parse("2006-01-02", selected.EventDate) + if d1.Before(d2) { + selected = s + } + } + } + + err = mail.Mail.Enqueue(&mail.EmailRequest{ + To: []string{}, + Subject: "Event Registration - Anokha 2026", + Type: "event-reg", + Data: &mail.RegistrationData{ + UserName: student.Name, + EventName: event.EventName, + EventDate: selected.EventDate, + EventTime: selected.StartTime + " - " + selected.EndTime, + EventLocation: selected.Venue, + }, + Retries: mail.MaxRetryCount, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Oops! Something happened. Please try again later", + }) + pkg.Log.ErrorCtx(c, "[MAIL-ERROR]: Failed to add request to email queue", err) + return + } + pkg.Log.SuccessCtx(c) + c.JSON(http.StatusOK, gin.H{ + "message": "Payment verified successfully", + "status": models.PaymentSuccess, + }) + return + } + pkg.Log.WarnCtx(c, "[VERIFY-WARN]: Verification successful, but status still pending for "+req.TxnID) + c.JSON(http.StatusOK, gin.H{ + "message": "Payment still pending", + "status": models.PaymentPending, + }) + + } +} diff --git a/bruno/booking (need auth)/Reverify Failed.bru b/bruno/booking (need auth)/Reverify Failed.bru new file mode 100644 index 00000000..550565b6 --- /dev/null +++ b/bruno/booking (need auth)/Reverify Failed.bru @@ -0,0 +1,15 @@ +meta { + name: Reverify Failed + type: http + seq: 5 +} + +get { + url: {{baseUrl + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/db/gen/booking-for-student.sql.go b/db/gen/booking-for-student.sql.go index 2e8b6f0f..8478f578 100644 --- a/db/gen/booking-for-student.sql.go +++ b/db/gen/booking-for-student.sql.go @@ -21,20 +21,22 @@ INSERT INTO bookings ( txn_status, product_info, seats_released, - metadata -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + metadata, + registration_fee_without_gst +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id ` type CreateBookingParams struct { - EventID uuid.UUID `json:"event_id"` - StudentID uuid.UUID `json:"student_id"` - TxnID string `json:"txn_id"` - RegistrationFee int32 `json:"registration_fee"` - TxnStatus string `json:"txn_status"` - ProductInfo string `json:"product_info"` - SeatsReleased int32 `json:"seats_released"` - Metadata []byte `json:"metadata"` + EventID uuid.UUID `json:"event_id"` + StudentID uuid.UUID `json:"student_id"` + TxnID string `json:"txn_id"` + RegistrationFee int32 `json:"registration_fee"` + TxnStatus string `json:"txn_status"` + ProductInfo string `json:"product_info"` + SeatsReleased int32 `json:"seats_released"` + Metadata []byte `json:"metadata"` + RegistrationFeeWithoutGst pgtype.Int4 `json:"registration_fee_without_gst"` } func (q *Queries) CreateBooking(ctx context.Context, db DBTX, arg CreateBookingParams) (uuid.UUID, error) { @@ -47,6 +49,7 @@ func (q *Queries) CreateBooking(ctx context.Context, db DBTX, arg CreateBookingP arg.ProductInfo, arg.SeatsReleased, arg.Metadata, + arg.RegistrationFeeWithoutGst, ) var id uuid.UUID err := row.Scan(&id) @@ -238,6 +241,34 @@ func (q *Queries) GetBookingByTxnID(ctx context.Context, db DBTX, txnID string) return i, err } +const getDisputeIdFromTxnIdQuery = `-- name: GetDisputeIdFromTxnIdQuery :one +SELECT d.id +FROM dispute d +WHERE d.txn_id = $1 +` + +func (q *Queries) GetDisputeIdFromTxnIdQuery(ctx context.Context, db DBTX, txnID string) (uuid.UUID, error) { + row := db.QueryRow(ctx, getDisputeIdFromTxnIdQuery, txnID) + var id uuid.UUID + err := row.Scan(&id) + return id, err +} + +const getEmailByTxnId = `-- name: GetEmailByTxnId :one +SELECT s.email +FROM bookings b +INNER JOIN student s + ON b.student_id = s.id +WHERE b.txn_id = $1 +` + +func (q *Queries) GetEmailByTxnId(ctx context.Context, db DBTX, txnID string) (string, error) { + row := db.QueryRow(ctx, getEmailByTxnId, txnID) + var email string + err := row.Scan(&email) + return email, err +} + const getEventForBooking = `-- name: GetEventForBooking :one SELECT e.id, @@ -294,7 +325,10 @@ func (q *Queries) GetEventForBooking(ctx context.Context, db DBTX, id uuid.UUID) } const getTeamIDByBooking = `-- name: GetTeamIDByBooking :one -SELECT id FROM teams WHERE booking_id = $1 +SELECT teams.id FROM teams +INNER JOIN bookings b + ON teams.booking_id = b.id +WHERE booking_id = $1 ` func (q *Queries) GetTeamIDByBooking(ctx context.Context, db DBTX, bookingID uuid.UUID) (uuid.UUID, error) { @@ -305,18 +339,51 @@ func (q *Queries) GetTeamIDByBooking(ctx context.Context, db DBTX, bookingID uui } const getTeamMembersByTeamID = `-- name: GetTeamMembersByTeamID :many -SELECT id, team_id, student_id, student_role, student_name, student_email FROM team_members WHERE team_id = $1 +SELECT team_members.id, team_id, team_members.student_id, student_role, student_name, student_email, t.id, team_name, t.event_id, leader_name, booking_id, t.metadata, b.id, txn_id, b.student_id, b.event_id, registration_fee, product_info, seats_released, txn_status, team_details, b.metadata, created_at, updated_at, registration_fee_without_gst FROM team_members +INNER JOIN teams t + ON team_members.team_id = t.id +INNER JOIN bookings b + ON t.booking_id = b.id +WHERE team_id = $1 ` -func (q *Queries) GetTeamMembersByTeamID(ctx context.Context, db DBTX, teamID uuid.UUID) ([]TeamMember, error) { +type GetTeamMembersByTeamIDRow struct { + ID uuid.UUID `json:"id"` + TeamID uuid.UUID `json:"team_id"` + StudentID uuid.UUID `json:"student_id"` + StudentRole string `json:"student_role"` + StudentName string `json:"student_name"` + StudentEmail string `json:"student_email"` + ID_2 uuid.UUID `json:"id_2"` + TeamName string `json:"team_name"` + EventID uuid.UUID `json:"event_id"` + LeaderName string `json:"leader_name"` + BookingID uuid.UUID `json:"booking_id"` + Metadata []byte `json:"metadata"` + ID_3 uuid.UUID `json:"id_3"` + TxnID string `json:"txn_id"` + StudentID_2 uuid.UUID `json:"student_id_2"` + EventID_2 uuid.UUID `json:"event_id_2"` + RegistrationFee int32 `json:"registration_fee"` + ProductInfo string `json:"product_info"` + SeatsReleased int32 `json:"seats_released"` + TxnStatus string `json:"txn_status"` + TeamDetails []byte `json:"team_details"` + Metadata_2 []byte `json:"metadata_2"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` + RegistrationFeeWithoutGst pgtype.Int4 `json:"registration_fee_without_gst"` +} + +func (q *Queries) GetTeamMembersByTeamID(ctx context.Context, db DBTX, teamID uuid.UUID) ([]GetTeamMembersByTeamIDRow, error) { rows, err := db.Query(ctx, getTeamMembersByTeamID, teamID) if err != nil { return nil, err } defer rows.Close() - var items []TeamMember + var items []GetTeamMembersByTeamIDRow for rows.Next() { - var i TeamMember + var i GetTeamMembersByTeamIDRow if err := rows.Scan( &i.ID, &i.TeamID, @@ -324,6 +391,25 @@ func (q *Queries) GetTeamMembersByTeamID(ctx context.Context, db DBTX, teamID uu &i.StudentRole, &i.StudentName, &i.StudentEmail, + &i.ID_2, + &i.TeamName, + &i.EventID, + &i.LeaderName, + &i.BookingID, + &i.Metadata, + &i.ID_3, + &i.TxnID, + &i.StudentID_2, + &i.EventID_2, + &i.RegistrationFee, + &i.ProductInfo, + &i.SeatsReleased, + &i.TxnStatus, + &i.TeamDetails, + &i.Metadata_2, + &i.CreatedAt, + &i.UpdatedAt, + &i.RegistrationFeeWithoutGst, ); err != nil { return nil, err } diff --git a/db/migrations/018_edit_analytics_materialized_view.sql b/db/migrations/018_edit_analytics_materialized_view.sql new file mode 100644 index 00000000..c8451ea0 --- /dev/null +++ b/db/migrations/018_edit_analytics_materialized_view.sql @@ -0,0 +1,74 @@ +-- +goose Up + +-- +goose StatementBegin +DROP MATERIALIZED VIEW IF EXISTS event_registration_analytics; +DROP MATERIALIZED VIEW IF EXISTS participants_analytics; + +CREATE MATERIALIZED VIEW participants_analytics AS +WITH participants AS ( + SELECT student_id FROM solo_event_participant + UNION + SELECT student_id FROM team_events_attendance + ) +SELECT + 1 AS id, + + -- TOTAL EVENT REGISTRATIONS + (SELECT COUNT(*) FROM participants) AS total_event_participants, + + -- PARTICIPANTS VS NON-PARTICIPANTS + ( + SELECT jsonb_build_object( + 'participants', (SELECT COUNT(*) FROM participants), + 'non_participants', + (SELECT COUNT(*) FROM student s + WHERE s.id NOT IN (SELECT student_id FROM participants)) + ) + ) AS participant_split, + + -- Amrita vs Non-Amrita Participants + ( + SELECT jsonb_build_object( + 'amrita_participants', + (SELECT COUNT(*) FROM participants p + JOIN student s ON s.id = p.student_id + WHERE s.is_amrita_student = TRUE), + 'non_amrita_participants', + (SELECT COUNT(*) FROM participants p + JOIN student s ON s.id = p.student_id + WHERE s.is_amrita_student = FALSE) + ) + ) AS amrita_non_amrita_split, + + -- (REGISTRATIONS VS TOTAL SEATS) PER EVENT + ( + SELECT jsonb_agg(row_to_json(x)) + FROM ( + SELECT + e.id AS event_id, + e.name AS event_name, + e.total_seats, + COUNT(DISTINCT CASE WHEN b_sp.txn_status = 'SUCCESS' THEN sp.student_id END) + + COUNT(DISTINCT CASE WHEN b_t.txn_status = 'SUCCESS' THEN tm.student_id END) AS registered_count + FROM event e + LEFT JOIN solo_event_participant sp ON sp.event_id = e.id + LEFT JOIN bookings b_sp ON b_sp.id = sp.booking_id + LEFT JOIN teams t ON t.event_id = e.id + LEFT JOIN bookings b_t ON b_t.id = t.booking_id + LEFT JOIN team_members tm ON tm.team_id = t.id + GROUP BY e.id, e.name, e.total_seats + ORDER BY e.name + ) x + ) AS event_registration_stats; + + +-- Create unique index for CONCURRENT refresh support +CREATE UNIQUE INDEX participants_id_index +ON participants_analytics(id); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP MATERIALIZED VIEW IF EXISTS participants_analytics; +-- +goose StatementEnd diff --git a/db/queries/booking-for-student.sql b/db/queries/booking-for-student.sql index 6a0eb0c0..dd197ce3 100644 --- a/db/queries/booking-for-student.sql +++ b/db/queries/booking-for-student.sql @@ -7,8 +7,9 @@ INSERT INTO bookings ( txn_status, product_info, seats_released, - metadata -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + metadata, + registration_fee_without_gst +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id; -- name: CreateTeam :one @@ -92,10 +93,18 @@ WHERE id = $2; SELECT * FROM bookings WHERE txn_id = $1; -- name: GetTeamMembersByTeamID :many -SELECT * FROM team_members WHERE team_id = $1; +SELECT * FROM team_members +INNER JOIN teams t + ON team_members.team_id = t.id +INNER JOIN bookings b + ON t.booking_id = b.id +WHERE team_id = $1; -- name: GetTeamIDByBooking :one -SELECT id FROM teams WHERE booking_id = $1; +SELECT teams.id FROM teams +INNER JOIN bookings b + ON teams.booking_id = b.id +WHERE booking_id = $1; -- name: DeleteTeam :exec DELETE @@ -110,4 +119,16 @@ WHERE team_id = $1; -- name: UpdateBookingStatus :exec UPDATE bookings SET txn_status = $2 -WHERE id = $1; \ No newline at end of file +WHERE id = $1; + +-- name: GetEmailByTxnId :one +SELECT s.email +FROM bookings b +INNER JOIN student s + ON b.student_id = s.id +WHERE b.txn_id = $1; + +-- name: GetDisputeIdFromTxnIdQuery :one +SELECT d.id +FROM dispute d +WHERE d.txn_id = $1; diff --git a/models/transaction.go b/models/transaction.go index afb68c49..34b7c73b 100644 --- a/models/transaction.go +++ b/models/transaction.go @@ -58,3 +58,16 @@ func MapPayUStatus(res PayUVerifyResponse, txnID string) string { return PaymentNotFound } } + +type ReverifyTransactionRequest struct { + TxnID string `json:"txn_id" binding:"required"` +} + +func (s ReverifyTransactionRequest) Validate() error { + return v.ValidateStruct(&s, + v.Field(&s.TxnID, + v.Required, + v.Match(pkg.Txn_regex). + Error("txn_id is not valid")), + ) +}