From aef5c4410d72836b3257c30e045280082f03a10e Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Fri, 30 May 2025 21:54:22 +0300 Subject: [PATCH] chapa integration fix --- go.mod | 1 + go.sum | 2 + internal/domain/chapa.go | 44 ++++++++ internal/domain/common.go | 9 ++ internal/domain/responses.go | 53 ++++++++++ internal/services/chapa/port.go | 11 ++ internal/services/chapa/service.go | 128 +++++++++++++++++++++++ internal/services/result/service.go | 2 +- internal/services/result/sports_eval.go | 4 +- internal/web_server/handlers/chapa.go | 58 ++++++++++ internal/web_server/handlers/handlers.go | 4 + 11 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 internal/domain/responses.go create mode 100644 internal/services/chapa/port.go create mode 100644 internal/services/chapa/service.go diff --git a/go.mod b/go.mod index 5a55392..557251b 100644 --- a/go.mod +++ b/go.mod @@ -50,6 +50,7 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/shopspring/decimal v1.4.0 github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect diff --git a/go.sum b/go.sum index ab1ac26..32967eb 100644 --- a/go.sum +++ b/go.sum @@ -112,6 +112,8 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= diff --git a/internal/domain/chapa.go b/internal/domain/chapa.go index f630a6d..885f6ad 100644 --- a/internal/domain/chapa.go +++ b/internal/domain/chapa.go @@ -105,3 +105,47 @@ type VerifyTransferResponse struct { Message string `json:"message"` Data TransferVerificationData `json:"data"` } + +type ChapaTransactionType struct { + Type string `json:"type"` +} + +type ChapaWebHookTransfer struct { + AccountName string `json:"account_name"` + AccountNumber string `json:"account_number"` + BankId string `json:"bank_id"` + BankName string `json:"bank_name"` + Currency string `json:"currency"` + Amount string `json:"amount"` + Type string `json:"type"` + Status string `json:"status"` + Reference string `json:"reference"` + TxRef string `json:"tx_ref"` + ChapaReference string `json:"chapa_reference"` + CreatedAt time.Time `json:"created_at"` +} + +type ChapaWebHookPayment struct { + Event string `json:"event"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + Mobile interface{} `json:"mobile"` + Currency string `json:"currency"` + Amount string `json:"amount"` + Charge string `json:"charge"` + Status string `json:"status"` + Mode string `json:"mode"` + Reference string `json:"reference"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Type string `json:"type"` + TxRef string `json:"tx_ref"` + PaymentMethod string `json:"payment_method"` + Customization struct { + Title interface{} `json:"title"` + Description interface{} `json:"description"` + Logo interface{} `json:"logo"` + } `json:"customization"` + Meta string `json:"meta"` +} diff --git a/internal/domain/common.go b/internal/domain/common.go index fc652d1..6e0acbb 100644 --- a/internal/domain/common.go +++ b/internal/domain/common.go @@ -47,3 +47,12 @@ func (m Currency) String() string { x = x / 100 return fmt.Sprintf("$%.2f", x) } + +type Response struct { + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` + Success bool `json:"success"` + StatusCode int `json:"status_code"` +} + + diff --git a/internal/domain/responses.go b/internal/domain/responses.go new file mode 100644 index 0000000..841ca37 --- /dev/null +++ b/internal/domain/responses.go @@ -0,0 +1,53 @@ +package domain + +import ( + "errors" + + "github.com/gofiber/fiber/v2" +) + +func UnProcessableEntityResponse(c *fiber.Ctx) error { + return c.Status(fiber.StatusUnprocessableEntity).JSON(Response{ + Message: "failed to parse request body", + StatusCode: fiber.StatusUnprocessableEntity, + Success: false, + }) +} + +func FiberErrorResponse(c *fiber.Ctx, err error) error { + var statusCode int + var message string + + switch { + case errors.Is(err, fiber.ErrNotFound): + statusCode = fiber.StatusNotFound + message = "resource not found" + + case errors.Is(err, fiber.ErrBadRequest): + statusCode = fiber.StatusBadRequest + message = "bad request" + + case errors.Is(err, fiber.ErrUnauthorized): + statusCode = fiber.StatusUnauthorized + message = "unauthorized" + + case errors.Is(err, fiber.ErrForbidden): + statusCode = fiber.StatusForbidden + message = "forbidden" + + case errors.Is(err, fiber.ErrConflict): + statusCode = fiber.StatusConflict + message = "conflict occurred" + + default: + statusCode = fiber.StatusInternalServerError + message = "unexpected server error" + } + + return c.Status(statusCode).JSON(fiber.Map{ + "success": false, + "status_code": statusCode, + "error": message, + "details": err.Error(), + }) +} diff --git a/internal/services/chapa/port.go b/internal/services/chapa/port.go new file mode 100644 index 0000000..2c0d267 --- /dev/null +++ b/internal/services/chapa/port.go @@ -0,0 +1,11 @@ +package chapa + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +type ChapaPort interface { + HandleChapaTransferWebhook(ctx context.Context, req domain.ChapaWebHookTransfer) error +} diff --git a/internal/services/chapa/service.go b/internal/services/chapa/service.go new file mode 100644 index 0000000..2272381 --- /dev/null +++ b/internal/services/chapa/service.go @@ -0,0 +1,128 @@ +package chapa + +import ( + "context" + "fmt" + "strconv" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" + "github.com/shopspring/decimal" +) + +type Service struct { + transactionStore transaction.TransactionStore + walletStore wallet.WalletStore + store *repository.Store +} + +func (s *Service) HandleChapaTransferWebhook(ctx context.Context, req domain.ChapaWebHookTransfer) error { + tx, err := s.store.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + // 1. Fetch transaction + referenceID, err := strconv.ParseInt(req.Reference, 10, 64) + txn, err := s.transactionStore.GetTransactionByID(ctx, referenceID) + if err != nil { + return err + } + if txn.Verified { + return nil // already processed + } + + // 2. Compare amount + webhookAmount, _ := decimal.NewFromString(req.Amount) + storedAmount, _ := decimal.NewFromString(txn.Amount.String()) + if !webhookAmount.Equal(storedAmount) { + return fmt.Errorf("amount mismatch") + } + + // 3. Update transaction + txn.Verified = true + + if err := s.transactionStore.UpdateTransactionVerified(ctx, txn.ID, txn.Verified, txn.ApprovedBy.Value, txn.ApproverName.Value); err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.ChapaWebHookPayment) error { + tx, err := s.store.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + if req.Status != "success" { + return fmt.Errorf("payment status not successful") + } + + // 1. Parse reference ID + referenceID, err := strconv.ParseInt(req.TxRef, 10, 64) + if err != nil { + return fmt.Errorf("invalid tx_ref: %w", err) + } + + // 2. Fetch transaction + txn, err := s.transactionStore.GetTransactionByID(ctx, referenceID) + if err != nil { + return err + } + if txn.Verified { + return nil // already processed + } + + // 3. Amount validation + webhookAmount, _ := decimal.NewFromString(req.Amount) + storedAmount := txn.Amount // assuming it's domain.Currency (decimal.Decimal alias) + + if webhookAmount.LessThan(storedAmount) { + return fmt.Errorf("webhook amount is less than expected") + } + + // 4. Fetch wallet + wallet, err := s.walletStore.GetWalletByID(ctx, txn.ID) + if err != nil { + return err + } + + // 5. Update wallet balance + newBalance := wallet.Balance.Add(storedAmount) + if err := s.walletStore.UpdateBalance(ctx, wallet.ID, newBalance); err != nil { + return err + } + + // 6. Mark transaction as verified + if err := s.transactionStore.UpdateTransactionVerified(ctx, txn.ID, true, txn.ApprovedBy.Value, txn.ApproverName.Value); err != nil { + return err + } + + // 7. Check & generate referral code + hasCode, err := s.userStore.HasReferralCode(ctx, wallet.UserID) + if err != nil { + return err + } + + if !hasCode { + code := misc.GenerateReferralCode(req.FirstName) + + if err := s.userStore.SetReferralCode(ctx, wallet.UserID, code); err != nil { + return err + } + + if err := s.referralStore.CreateReferralCode(ctx, domain.ReferralCode{ + Code: code, + Amount: config.ReferralRewardBase, + }); err != nil { + return err + } + } + + return tx.Commit(ctx) +} diff --git a/internal/services/result/service.go b/internal/services/result/service.go index 4c3fa81..9d15b7f 100644 --- a/internal/services/result/service.go +++ b/internal/services/result/service.go @@ -730,7 +730,7 @@ func (s *Service) evaluateIceHockeyOutcome(outcome domain.BetOutcome, res domain return domain.OUTCOME_STATUS_PENDING, nil } -func (s *Service) evaluateNFLOutcome(outcome domain.BetOutcome, finalScore struct{ Home, Away int }) (domain.OutcomeStatus, error) { +func (s *Service) EvaluateNFLOutcome(outcome domain.BetOutcome, finalScore struct{ Home, Away int }) (domain.OutcomeStatus, error) { if !domain.SupportedMarkets[outcome.MarketID] { s.logger.Warn("Unsupported market type", "market_name", outcome.MarketName) return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("unsupported market type: %s", outcome.MarketName) diff --git a/internal/services/result/sports_eval.go b/internal/services/result/sports_eval.go index eeb23f7..48a837f 100644 --- a/internal/services/result/sports_eval.go +++ b/internal/services/result/sports_eval.go @@ -234,7 +234,7 @@ func evaluateBaseballTotalRuns(outcome domain.BetOutcome, score struct{ Home, Aw } // evaluateBaseballFirstInning evaluates Baseball first inning bets -func evaluateBaseballFirstInning(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { +func EvaluateBaseballFirstInning(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { switch outcome.OddHeader { case "1": if score.Home > score.Away { @@ -257,7 +257,7 @@ func evaluateBaseballFirstInning(outcome domain.BetOutcome, score struct{ Home, } // evaluateBaseballFirst5Innings evaluates Baseball first 5 innings bets -func evaluateBaseballFirst5Innings(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { +func EvaluateBaseballFirst5Innings(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { switch outcome.OddHeader { case "1": if score.Home > score.Away { diff --git a/internal/web_server/handlers/chapa.go b/internal/web_server/handlers/chapa.go index 3fc66c0..0e69c83 100644 --- a/internal/web_server/handlers/chapa.go +++ b/internal/web_server/handlers/chapa.go @@ -7,6 +7,8 @@ import ( "io" "net/http" + "github.com/SamuelTariku/FortuneBet-Backend/internal/config" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/gofiber/fiber/v2" "github.com/google/uuid" ) @@ -281,3 +283,59 @@ func (h *Handler) VerifyTransfer(c *fiber.Ctx) error { return c.Status(resp.StatusCode).Type("json").Send(body) } + +// VerifyChapaPayment godoc +// @Summary Verifies Chapa webhook transaction +// @Tags Chapa +// @Accept json +// @Produce json +// @Param payload body domain.ChapaTransactionType true "Webhook Payload" +// @Success 200 {object} domain.Response +// @Router /api/v1/chapa/payments/verify [post] +func (h *Handler) VerifyChapaPayment(c *fiber.Ctx) error { + var txType domain.ChapaTransactionType + if err := c.BodyParser(&txType); err != nil { + return domain.UnProcessableEntityResponse(c) + } + + switch txType.Type { + case config.ChapaTransferType: + var payload domain.ChapaWebHookTransfer + if err := c.BodyParser(&payload); err != nil { + return domain.UnProcessableEntityResponse(c) + } + + if err := h.chapaSvc.HandleChapaTransferWebhook(c.Context(), payload); err != nil { + return domain.FiberErrorResponse(c, err) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Chapa transfer verified successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) + + case config.ChapaPaymentType: + var payload domain.ChapaWebHookPayment + if err := c.BodyParser(&payload); err != nil { + return domain.UnProcessableEntityResponse(c) + } + + if err := h.chapaSvc.HandleChapaPaymentWebhook(c.Context(), payload); err != nil { + return domain.FiberErrorResponse(c, err) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Chapa payment verified successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) + + default: + return c.Status(fiber.StatusBadRequest).JSON(domain.Response{ + Message: "Invalid Chapa transaction type", + Success: false, + StatusCode: fiber.StatusBadRequest, + }) + } +} diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 532d2da..6c42024 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -7,6 +7,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/chapa" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/company" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" @@ -29,6 +30,7 @@ type Handler struct { notificationSvc *notificationservice.Service userSvc *user.Service referralSvc referralservice.ReferralStore + chapaSvc chapa.ChapaPort walletSvc *wallet.Service transactionSvc *transaction.Service ticketSvc *ticket.Service @@ -51,6 +53,7 @@ func New( logger *slog.Logger, notificationSvc *notificationservice.Service, validator *customvalidator.CustomValidator, + chapaSvc chapa.ChapaPort, walletSvc *wallet.Service, referralSvc referralservice.ReferralStore, virtualGameSvc virtualgameservice.VirtualGameService, @@ -72,6 +75,7 @@ func New( return &Handler{ logger: logger, notificationSvc: notificationSvc, + chapaSvc: chapaSvc, walletSvc: walletSvc, referralSvc: referralSvc, validator: validator,