package chapa import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "strings" "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "github.com/google/uuid" ) type Service struct { transferStore wallet.TransferStore walletStore wallet.Service userStore user.UserStore cfg *config.Config chapaClient *Client } func NewService( transferStore wallet.TransferStore, walletStore wallet.Service, userStore user.UserStore, cfg *config.Config, chapaClient *Client, ) *Service { return &Service{ transferStore: transferStore, walletStore: walletStore, userStore: userStore, cfg: cfg, chapaClient: chapaClient, } } func (s *Service) VerifyWebhookSignature(ctx context.Context, payload []byte, chapaSignature, xChapaSignature string) (bool, error) { secret := s.cfg.CHAPA_WEBHOOK_SECRET // or os.Getenv("CHAPA_SECRET_KEY") if secret == "" { return false, fmt.Errorf("missing Chapa secret key in configuration") } // Compute expected signature using HMAC SHA256 h := hmac.New(sha256.New, []byte(secret)) h.Write(payload) expected := hex.EncodeToString(h.Sum(nil)) // Check either header if chapaSignature == expected || xChapaSignature == expected { return true, nil } // Optionally log for debugging var pretty map[string]interface{} _ = json.Unmarshal(payload, &pretty) fmt.Printf("[Webhook Verification Failed]\nExpected: %s\nGot chapa-signature: %s\nGot x-chapa-signature: %s\nPayload: %+v\n", expected, chapaSignature, xChapaSignature, pretty) return false, fmt.Errorf("invalid webhook signature") } // InitiateDeposit starts a new deposit process func (s *Service) InitiateDeposit(ctx context.Context, userID int64, amount domain.Currency) (string, error) { // Validate amount if amount <= 0 { return "", domain.ErrInvalidPaymentAmount } // Get user details user, err := s.userStore.GetUserByID(ctx, userID) if err != nil { return "", fmt.Errorf("failed to get user: %w", err) } // var senderWallet domain.Wallet // Generate unique reference // reference := uuid.New().String() reference := fmt.Sprintf("chapa-deposit-%d-%s", userID, uuid.New().String()) senderWallet, err := s.walletStore.GetCustomerWallet(ctx, userID) if err != nil { return "", fmt.Errorf("failed to get sender wallet: %w", err) } // for _, wallet := range senderWallets { // if wallet.IsTransferable { // senderWallet = wallet // break // } // } // Check if payment with this reference already exists // if transfer, err := s.transferStore.GetTransferByReference(ctx, reference); err == nil { // return fmt.Sprintf("%v", transfer), ErrPaymentAlreadyExists // } // Create payment record transfer := domain.CreateTransfer{ Message: fmt.Sprintf("Depositing %v into wallet using chapa. Reference Number %v", amount.Float32(), reference), Amount: amount, Type: domain.DEPOSIT, PaymentMethod: domain.TRANSFER_CHAPA, ReferenceNumber: reference, // ReceiverWalletID: 1, SenderWalletID: domain.ValidInt64{ Value: senderWallet.RegularID, Valid: true, }, Verified: false, Status: string(domain.STATUS_PENDING), } userPhoneNum := user.PhoneNumber[len(user.PhoneNumber)-9:] if len(user.PhoneNumber) >= 9 { userPhoneNum = "0" + userPhoneNum } payload := domain.ChapaInitDepositRequest{ Amount: amount, Currency: "ETB", Email: user.Email, FirstName: user.FirstName, LastName: user.LastName, TxRef: reference, CallbackURL: s.cfg.CHAPA_CALLBACK_URL, ReturnURL: s.cfg.CHAPA_RETURN_URL, PhoneNumber: userPhoneNum, } // Initialize payment with Chapa response, err := s.chapaClient.InitializePayment(ctx, payload) fmt.Printf("\n\nChapa payload is: %+v\n\n", payload) if err != nil { // Update payment status to failed // _ = s.transferStore.(payment.ID, domain.PaymentStatusFailed) return "", fmt.Errorf("failed to initialize payment: %w", err) } tempTransfer, err := s.transferStore.CreateTransfer(ctx, transfer) if err != nil { return "", fmt.Errorf("failed to save payment: %w", err) } fmt.Printf("\n\nTemp transfer is: %v\n\n", tempTransfer) return response.CheckoutURL, nil } func (s *Service) ProcessVerifyDepositWebhook(ctx context.Context, req domain.ChapaWebhookPayment) error { // Find payment by reference payment, err := s.transferStore.GetTransferByReference(ctx, req.TxRef) if err != nil { return domain.ErrPaymentNotFound } if payment.Verified { return nil } // Verify payment with Chapa // verification, err := s.chapaClient.VerifyPayment(ctx, transfer.Reference) // if err != nil { // return fmt.Errorf("failed to verify payment: %w", err) // } // Update payment status // verified := false // if transfer.Status == string(domain.PaymentStatusCompleted) { // verified = true // } // If payment is completed, credit user's wallet if req.Status == string(domain.PaymentStatusSuccessful) { if err := s.transferStore.UpdateTransferVerification(ctx, payment.ID, true); err != nil { return fmt.Errorf("failed to update is payment verified value: %w", err) } if err := s.transferStore.UpdateTransferStatus(ctx, payment.ID, string(domain.DepositStatusCompleted)); err != nil { return fmt.Errorf("failed to update payment status: %w", err) } if _, err := s.walletStore.AddToWallet(ctx, payment.SenderWalletID.Value, payment.Amount, domain.ValidInt64{}, domain.TRANSFER_CHAPA, domain.PaymentDetails{ ReferenceNumber: domain.ValidString{ Value: req.TxRef, }, }, fmt.Sprintf("Added %v to wallet using Chapa", payment.Amount)); err != nil { return fmt.Errorf("failed to credit user wallet: %w", err) } } return nil } func (s *Service) CancelDeposit(ctx context.Context, userID int64, txRef string) (domain.ChapaCancelResponse, error) { // Validate input if txRef == "" { return domain.ChapaCancelResponse{}, fmt.Errorf("transaction reference is required") } // Retrieve user to verify ownership / context (optional but good practice) user, err := s.userStore.GetUserByID(ctx, userID) if err != nil { return domain.ChapaCancelResponse{}, fmt.Errorf("failed to get user: %w", err) } fmt.Printf("\n\nAttempting to cancel Chapa transaction: %s for user %s (%d)\n\n", txRef, user.Email, userID) // Call Chapa API to cancel transaction cancelResp, err := s.chapaClient.CancelTransaction(ctx, txRef) if err != nil { return domain.ChapaCancelResponse{}, fmt.Errorf("failed to cancel transaction via Chapa: %w", err) } // Update transfer/payment status locally transfer, err := s.transferStore.GetTransferByReference(ctx, txRef) if err != nil { // Log but do not block cancellation if remote succeeded fmt.Printf("Warning: unable to find local transfer for txRef %s: %v\n", txRef, err) } else { if err := s.transferStore.UpdateTransferStatus(ctx, transfer.ID, string(domain.STATUS_CANCELLED)); err != nil { fmt.Printf("Warning: failed to update transfer status for txRef %s: %v\n", txRef, err) } if err := s.transferStore.UpdateTransferVerification(ctx, transfer.ID, false); err != nil { fmt.Printf("Warning: failed to update transfer status for txRef %s: %v\n", txRef, err) } } fmt.Printf("\n\nChapa cancellation response: %+v\n\n", cancelResp) return cancelResp, nil } func (s *Service) FetchAllTransactions(ctx context.Context) ([]domain.ChapaTransaction, error) { // Call Chapa API to get all transactions resp, err := s.chapaClient.GetAllTransactions(ctx) if err != nil { return nil, fmt.Errorf("failed to fetch transactions from Chapa: %w", err) } if resp.Status != "success" { return nil, fmt.Errorf("chapa API returned non-success status: %s", resp.Status) } transactions := make([]domain.ChapaTransaction, 0, len(resp.Data.Transactions)) // Map API transactions to domain transactions for _, t := range resp.Data.Transactions { tx := domain.ChapaTransaction{ Status: t.Status, RefID: t.RefID, Type: t.Type, CreatedAt: t.CreatedAt, Currency: t.Currency, Amount: t.Amount, Charge: t.Charge, TransID: t.TransID, PaymentMethod: t.PaymentMethod, Customer: domain.ChapaCustomer{ ID: t.Customer.ID, Email: t.Customer.Email, FirstName: t.Customer.FirstName, LastName: t.Customer.LastName, Mobile: t.Customer.Mobile, }, } transactions = append(transactions, tx) } return transactions, nil } func (s *Service) FetchTransactionEvents(ctx context.Context, refID string) ([]domain.ChapaTransactionEvent, error) { if refID == "" { return nil, fmt.Errorf("transaction reference ID is required") } // Call Chapa client to fetch transaction events events, err := s.chapaClient.GetTransactionEvents(ctx, refID) if err != nil { return nil, fmt.Errorf("failed to fetch transaction events from Chapa: %w", err) } // Optional: Transform or filter events if needed transformedEvents := make([]domain.ChapaTransactionEvent, 0, len(events)) for _, e := range events { transformedEvents = append(transformedEvents, domain.ChapaTransactionEvent{ Item: e.Item, Message: e.Message, Type: e.Type, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, }) } return transformedEvents, nil } func (s *Service) InitiateWithdrawal(ctx context.Context, userID int64, req domain.ChapaWithdrawalRequest) (*domain.Transfer, error) { // Parse and validate amount amount, err := strconv.ParseFloat(req.Amount, 64) if err != nil || amount <= 0 { return nil, domain.ErrInvalidWithdrawalAmount } // Get user's wallet wallet, err := s.walletStore.GetCustomerWallet(ctx, userID) if err != nil { return nil, fmt.Errorf("failed to get user wallets: %w", err) } // var withdrawWallet domain.Wallet // for _, wallet := range wallets { // if wallet.IsWithdraw { // withdrawWallet = wallet // break // } // } // if withdrawWallet.ID == 0 { // return nil, errors.New("withdrawal wallet not found") // } // Check balance if float64(wallet.RegularBalance) < float64(amount) { return nil, domain.ErrInsufficientBalance } // Generate unique reference reference := uuid.New().String() createTransfer := domain.CreateTransfer{ Message: fmt.Sprintf("Withdrawing %f into wallet using chapa. Reference Number %s", amount, reference), Amount: domain.Currency(amount), Type: domain.WITHDRAW, SenderWalletID: domain.ValidInt64{ Value: wallet.RegularID, Valid: true, }, Status: string(domain.PaymentStatusPending), Verified: false, ReferenceNumber: reference, PaymentMethod: domain.TRANSFER_CHAPA, } transfer, err := s.transferStore.CreateTransfer(ctx, createTransfer) if err != nil { return nil, fmt.Errorf("failed to create transfer record: %w", err) } // Initiate transfer with Chapa transferReq := domain.ChapaWithdrawalRequest{ AccountName: req.AccountName, AccountNumber: req.AccountNumber, Amount: fmt.Sprintf("%f", amount), Currency: req.Currency, Reference: reference, // BeneficiaryName: fmt.Sprintf("%s %s", user.FirstName, user.LastName), BankCode: req.BankCode, } newBalance := float64(wallet.RegularBalance) - float64(amount) if err := s.walletStore.UpdateBalance(ctx, wallet.RegularID, domain.Currency(newBalance)); err != nil { return nil, fmt.Errorf("failed to update wallet balance: %w", err) } success, err := s.chapaClient.InitializeTransfer(ctx, transferReq) if err != nil { _ = s.transferStore.UpdateTransferStatus(ctx, transfer.ID, string(domain.WithdrawalStatusFailed)) newBalance := float64(wallet.RegularBalance) + float64(amount) if err := s.walletStore.UpdateBalance(ctx, wallet.RegularID, domain.Currency(newBalance)); err != nil { return nil, fmt.Errorf("failed to update wallet balance: %w", err) } return nil, fmt.Errorf("failed to initiate transfer: %w", err) } if !success { _ = s.transferStore.UpdateTransferStatus(ctx, transfer.ID, string(domain.WithdrawalStatusFailed)) newBalance := float64(wallet.RegularBalance) + float64(amount) if err := s.walletStore.UpdateBalance(ctx, wallet.RegularID, domain.Currency(newBalance)); err != nil { return nil, fmt.Errorf("failed to update wallet balance: %w", err) } return nil, errors.New("chapa rejected the transfer request") } // Update withdrawal status to processing // if err := s.transferStore.UpdateTransferStatus(ctx, transfer.ID, string(domain.WithdrawalStatusProcessing)); err != nil { // return nil, fmt.Errorf("failed to update withdrawal status: %w", err) // } // Deduct from wallet (or wait for webhook confirmation depending on your flow) return &transfer, nil } func (s *Service) ProcessVerifyWithdrawWebhook(ctx context.Context, req domain.ChapaWebhookTransfer) error { // Find payment by reference transfer, err := s.transferStore.GetTransferByReference(ctx, req.Reference) if err != nil { return domain.ErrPaymentNotFound } if transfer.Verified { return nil } // Verify payment with Chapa // verification, err := s.chapaClient.VerifyPayment(ctx, payment.Reference) // if err != nil { // return fmt.Errorf("failed to verify payment: %w", err) // } // Update payment status // verified := false // if transfer.Status == string(domain.PaymentStatusCompleted) { // verified = true // } if req.Status == string(domain.PaymentStatusSuccessful) { if err := s.transferStore.UpdateTransferVerification(ctx, transfer.ID, true); err != nil { return fmt.Errorf("failed to update payment status: %w", err) } // If payment is completed, credit user's walle } else { _, err := s.walletStore.AddToWallet(ctx, transfer.SenderWalletID.Value, transfer.Amount, domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}, fmt.Sprintf("Added %v to wallet by system because chapa withdraw is unsuccessful", transfer.Amount)) if err != nil { return fmt.Errorf("failed to credit user wallet: %w", err) } } return nil } func (s *Service) GetPaymentReceiptURL(refId string) (string, error) { if refId == "" { return "", fmt.Errorf("reference ID cannot be empty") } receiptURL := s.cfg.CHAPA_RECEIPT_URL + refId return receiptURL, nil } func (s *Service) ManuallyVerify(ctx context.Context, txRef string) (any, error) { // Lookup transfer by reference transfer, err := s.transferStore.GetTransferByReference(ctx, txRef) if err != nil { return nil, fmt.Errorf("transfer not found for reference %s: %w", txRef, err) } // If already verified, just return a completed response if transfer.Verified { return map[string]any{}, errors.New("transfer already verified") } // Validate sender wallet if !transfer.SenderWalletID.Valid { return nil, fmt.Errorf("invalid sender wallet ID: %v", transfer.SenderWalletID) } var verification any switch strings.ToLower(string(transfer.Type)) { case string(domain.DEPOSIT): // Verify Chapa payment verification, err := s.chapaClient.ManualVerifyPayment(ctx, txRef) if err != nil { return nil, fmt.Errorf("failed to verify deposit with Chapa: %w", err) } if strings.ToLower(verification.Data.Status) == "success" || verification.Status == string(domain.PaymentStatusCompleted) { // Credit wallet _, err := s.walletStore.AddToWallet(ctx, transfer.SenderWalletID.Value, transfer.Amount, domain.ValidInt64{}, domain.TRANSFER_CHAPA, domain.PaymentDetails{}, fmt.Sprintf("Added %.2f ETB to wallet using Chapa", transfer.Amount.Float32())) if err != nil { return nil, fmt.Errorf("failed to credit wallet: %w", err) } // Mark verified in DB if err := s.transferStore.UpdateTransferVerification(ctx, transfer.ID, true); err != nil { return nil, fmt.Errorf("failed to mark deposit transfer as verified: %w", err) } if err := s.transferStore.UpdateTransferStatus(ctx, transfer.ID, string(domain.DepositStatusCompleted)); err != nil { return nil, fmt.Errorf("failed to update deposit transfer status: %w", err) } } case string(domain.WITHDRAW): // Verify Chapa transfer verification, err := s.chapaClient.ManualVerifyTransfer(ctx, txRef) if err != nil { return nil, fmt.Errorf("failed to verify withdrawal with Chapa: %w", err) } if strings.ToLower(verification.Data.Status) == "success" || verification.Status == string(domain.PaymentStatusCompleted) { // Deduct wallet _, err := s.walletStore.DeductFromWallet(ctx, transfer.SenderWalletID.Value, transfer.Amount, domain.ValidInt64{}, domain.TRANSFER_CHAPA, fmt.Sprintf("Deducted %.2f ETB from wallet using Chapa", transfer.Amount.Float32())) if err != nil { return nil, fmt.Errorf("failed to debit wallet: %w", err) } // Mark verified in DB if err := s.transferStore.UpdateTransferVerification(ctx, transfer.ID, true); err != nil { return nil, fmt.Errorf("failed to mark withdraw transfer as verified: %w", err) } if err := s.transferStore.UpdateTransferStatus(ctx, transfer.ID, string(domain.WithdrawalStatusCompleted)); err != nil { return nil, fmt.Errorf("failed to update withdraw transfer status: %w", err) } } default: return nil, fmt.Errorf("unsupported transfer type: %s", transfer.Type) } return verification, nil } func (s *Service) GetSupportedBanks(ctx context.Context) ([]domain.BankData, error) { banks, err := s.chapaClient.FetchSupportedBanks(ctx) if err != nil { return nil, fmt.Errorf("failed to fetch banks: %w", err) } return banks, nil } func (s *Service) GetAllTransfers(ctx context.Context) (*domain.ChapaTransfersListResponse, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.cfg.CHAPA_BASE_URL+"/transfers", nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.cfg.CHAPA_SECRET_KEY)) resp, err := s.chapaClient.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch transfers: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(bodyBytes)) } var result domain.ChapaTransfersListResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } // Return the decoded result directly; no intermediate dynamic map needed return &result, nil } func (s *Service) GetAccountBalance(ctx context.Context, currencyCode string) ([]domain.Balance, error) { URL := s.cfg.CHAPA_BASE_URL + "/balances" if currencyCode != "" { URL = fmt.Sprintf("%s/%s", URL, strings.ToLower(currencyCode)) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, URL, nil) if err != nil { return nil, fmt.Errorf("failed to create balance request: %w", err) } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.cfg.CHAPA_SECRET_KEY)) resp, err := s.chapaClient.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute balance request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(bodyBytes)) } var result struct { Status string `json:"status"` Message string `json:"message"` Data []domain.Balance `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode balance response: %w", err) } return result.Data, nil } func (s *Service) SwapCurrency(ctx context.Context, reqBody domain.SwapRequest) (*domain.SwapResponse, error) { URL := s.cfg.CHAPA_BASE_URL + "/swap" // Normalize currency codes reqBody.From = strings.ToUpper(reqBody.From) reqBody.To = strings.ToUpper(reqBody.To) // Marshal request body body, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("failed to marshal swap payload: %w", err) } // Create HTTP request req, err := http.NewRequestWithContext(ctx, http.MethodPost, URL, bytes.NewBuffer(body)) if err != nil { return nil, fmt.Errorf("failed to create swap request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.cfg.CHAPA_SECRET_KEY)) // Execute request resp, err := s.chapaClient.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute swap request: %w", err) } defer resp.Body.Close() // Handle unexpected status if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(bodyBytes)) } // Decode response var result domain.SwapResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode swap response: %w", err) } return &result, nil } // func (s *Service) InitiateSwap(ctx context.Context, amount float64, from, to string) (*domain.SwapResponse, error) { // if amount < 1 { // return nil, fmt.Errorf("amount must be at least 1 USD") // } // if strings.ToUpper(from) != "USD" || strings.ToUpper(to) != "ETB" { // return nil, fmt.Errorf("only USD to ETB swap is supported") // } // payload := domain.SwapRequest{ // Amount: amount, // From: strings.ToUpper(from), // To: strings.ToUpper(to), // } // // payload := map[string]any{ // // "amount": amount, // // "from": strings.ToUpper(from), // // "to": strings.ToUpper(to), // // } // body, err := json.Marshal(payload) // if err != nil { // return nil, fmt.Errorf("failed to encode swap payload: %w", err) // } // req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.chapa.co/v1/swap", bytes.NewBuffer(body)) // if err != nil { // return nil, fmt.Errorf("failed to create swap request: %w", err) // } // req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.cfg.CHAPA_SECRET_KEY)) // req.Header.Set("Content-Type", "application/json") // resp, err := s.chapaClient.httpClient.Do(req) // if err != nil { // return nil, fmt.Errorf("failed to execute swap request: %w", err) // } // defer resp.Body.Close() // if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { // bodyBytes, _ := io.ReadAll(resp.Body) // return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(bodyBytes)) // } // var result struct { // Message string `json:"message"` // Status string `json:"status"` // Data domain.SwapResponse `json:"data"` // } // if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { // return nil, fmt.Errorf("failed to decode swap response: %w", err) // } // return &result.Data, nil // }