package chapa import ( "bytes" "context" "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, } } // 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()) senderWallets, err := s.walletStore.GetWalletsByUser(ctx, userID) if err != nil { return "", fmt.Errorf("failed to get sender wallets: %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.ID, Valid: true, }, Verified: false, } payload := domain.ChapaDepositRequest{ 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, } // 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) InitiateWithdrawal(ctx context.Context, userID int64, req domain.ChapaWithdrawalRequest) (*domain.Transfer, error) { // Parse and validate amount amount, err := strconv.ParseInt(req.Amount, 10, 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 %d 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("%d", amount), Currency: req.Currency, Reference: reference, // BeneficiaryName: fmt.Sprintf("%s %s", user.FirstName, user.LastName), BankCode: req.BankCode, } success, err := s.chapaClient.InitiateTransfer(ctx, transferReq) if err != nil { _ = s.transferStore.UpdateTransferStatus(ctx, transfer.ID, string(domain.WithdrawalStatusFailed)) return nil, fmt.Errorf("failed to initiate transfer: %w", err) } if !success { _ = s.transferStore.UpdateTransferStatus(ctx, transfer.ID, string(domain.WithdrawalStatusFailed)) 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) 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 &transfer, nil } func (s *Service) GetSupportedBanks(ctx context.Context) ([]domain.Bank, 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) ManuallyVerify(ctx context.Context, txRef string) (*domain.ChapaVerificationResponse, 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 transfer.Verified { return &domain.ChapaVerificationResponse{ Status: string(domain.PaymentStatusCompleted), Amount: float64(transfer.Amount) / 100, Currency: "ETB", }, nil } // Validate sender wallet if !transfer.SenderWalletID.Valid { return nil, fmt.Errorf("invalid sender wallet ID: %v", transfer.SenderWalletID) } var verification *domain.ChapaVerificationResponse // Decide verification method based on type switch strings.ToLower(string(transfer.Type)) { case "deposit": // Use Chapa Payment Verification verification, err = s.chapaClient.ManualVerifyPayment(ctx, txRef) if err != nil { return nil, fmt.Errorf("failed to verify deposit with Chapa: %w", err) } if verification.Status == string(domain.PaymentStatusSuccessful) { // Mark verified if err := s.transferStore.UpdateTransferVerification(ctx, transfer.ID, true); err != nil { return nil, fmt.Errorf("failed to mark deposit transfer as verified: %w", err) } // Credit wallet _, err := s.walletStore.AddToWallet(ctx, transfer.SenderWalletID.Value, transfer.Amount, domain.ValidInt64{}, domain.TRANSFER_CHAPA, domain.PaymentDetails{}, fmt.Sprintf("Added %v to wallet using Chapa", transfer.Amount.Float32())) if err != nil { return nil, fmt.Errorf("failed to credit wallet: %w", err) } } case "withdraw": // Use Chapa Transfer Verification verification, err = s.chapaClient.ManualVerifyTransfer(ctx, txRef) if err != nil { return nil, fmt.Errorf("failed to verify withdrawal with Chapa: %w", err) } if verification.Status == string(domain.PaymentStatusSuccessful) { // Mark verified (withdraw doesn't affect balance) if err := s.transferStore.UpdateTransferVerification(ctx, transfer.ID, true); err != nil { return nil, fmt.Errorf("failed to mark withdrawal transfer as verified: %w", err) } } default: return nil, fmt.Errorf("unsupported transfer type: %s", transfer.Type) } return verification, nil } func (s *Service) HandleVerifyDepositWebhook(ctx context.Context, transfer domain.ChapaWebHookTransfer) error { // Find payment by reference payment, err := s.transferStore.GetTransferByReference(ctx, transfer.Reference) 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 transfer.Status == string(domain.PaymentStatusSuccessful) { if err := s.transferStore.UpdateTransferVerification(ctx, payment.ID, true); 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: transfer.Reference, }, }, 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) HandleVerifyWithdrawWebhook(ctx context.Context, payment domain.ChapaWebHookPayment) error { // Find payment by reference transfer, err := s.transferStore.GetTransferByReference(ctx, payment.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 payment.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(ctx context.Context, chapaRef string) (string, error) { if chapaRef == "" { return "", fmt.Errorf("chapa reference ID is required") } receiptURL := fmt.Sprintf("https://chapa.link/payment-receipt/%s", chapaRef) return receiptURL, nil } func (s *Service) GetAllTransfers(ctx context.Context) ([]domain.Transfer, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.chapa.co/v1/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 struct { Status string `json:"status"` Message string `json:"message"` Data []domain.Transfer `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } return result.Data, nil } func (s *Service) GetAccountBalance(ctx context.Context, currencyCode string) ([]domain.Balance, error) { baseURL := "https://api.chapa.co/v1/balances" if currencyCode != "" { baseURL = fmt.Sprintf("%s/%s", baseURL, strings.ToLower(currencyCode)) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, 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) 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 }