Yimaru-BackEnd/internal/services/chapa/service.go
2025-10-20 15:16:39 +03:00

506 lines
15 KiB
Go

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
wallets, err := s.walletStore.GetWalletsByUser(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 withdrawWallet.Balance < domain.Currency(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: withdrawWallet.ID,
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 := withdrawWallet.Balance - domain.Currency(amount)
if err := s.walletStore.UpdateBalance(ctx, withdrawWallet.ID, 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
}