407 lines
12 KiB
Go
407 lines
12 KiB
Go
package telebirr
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/SamuelTariku/FortuneBet-Backend/internal/config"
|
|
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
|
|
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
|
|
)
|
|
|
|
// TokenResponse is the expected response from Telebirr
|
|
|
|
type TelebirrService struct {
|
|
// client TelebirrClient
|
|
cfg *config.Config
|
|
transferStore wallet.TransferStore
|
|
walletSvc *wallet.Service
|
|
}
|
|
|
|
func NewTelebirrService(cfg *config.Config, transferStore wallet.TransferStore, walletSvc *wallet.Service) *TelebirrService {
|
|
return &TelebirrService{
|
|
cfg: cfg,
|
|
transferStore: transferStore,
|
|
walletSvc: walletSvc,
|
|
}
|
|
}
|
|
|
|
// GetFabricToken fetches the fabric token from Telebirr
|
|
func GetTelebirrFabricToken(s *TelebirrService) (*domain.TelebirrFabricTokenResponse, error) {
|
|
// Prepare the request body
|
|
bodyMap := map[string]string{
|
|
"appSecret": s.cfg.TELEBIRR.TelebirrAppSecret,
|
|
}
|
|
bodyBytes, err := json.Marshal(bodyMap)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request body: %v", err)
|
|
}
|
|
|
|
// Prepare the HTTP request
|
|
req, err := http.NewRequest("POST", s.cfg.TELEBIRR.TelebirrBaseURL+"/payment/v1/token", bytes.NewBuffer(bodyBytes))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %v", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-APP-Key", s.cfg.TELEBIRR.TelebirrFabricAppID)
|
|
|
|
// Perform the request
|
|
client := &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to perform request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Read and parse the response
|
|
respBody, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response body: %v", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("non-200 response: %d, body: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var tokenResp domain.TelebirrFabricTokenResponse
|
|
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
return &tokenResp, nil
|
|
}
|
|
|
|
func (s *TelebirrService) CreateTelebirrOrder(title string, amount float32, userID int64) (string, error) {
|
|
// Step 1: Get Fabric Token
|
|
tokenResp, err := GetTelebirrFabricToken(s)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get token: %v", err)
|
|
}
|
|
fabricToken := tokenResp.Token
|
|
|
|
// Step 2: Create request object
|
|
orderID := fmt.Sprintf("%d", time.Now().UnixNano())
|
|
bizContent := domain.TelebirrBizContent{
|
|
NotifyURL: s.cfg.TELEBIRR.TelebirrCallbackURL, // Replace with actual
|
|
AppID: s.cfg.TELEBIRR.TelebirrFabricAppID,
|
|
MerchCode: s.cfg.TELEBIRR.TelebirrMerchantCode,
|
|
MerchOrderID: orderID,
|
|
TradeType: "Checkout",
|
|
Title: title,
|
|
TotalAmount: fmt.Sprintf("%.2f", amount),
|
|
TransCurrency: "ETB",
|
|
TimeoutExpress: "120m",
|
|
BusinessType: "WalletRefill",
|
|
PayeeIdentifier: s.cfg.TELEBIRR.TelebirrMerchantCode,
|
|
PayeeIdentifierType: "04",
|
|
PayeeType: "5000",
|
|
RedirectURL: s.cfg.ARIFPAY.SuccessUrl, // Replace with actual
|
|
CallbackInfo: "From web",
|
|
}
|
|
|
|
requestPayload := domain.TelebirrPreOrderRequestPayload{
|
|
Timestamp: fmt.Sprintf("%d", time.Now().Unix()),
|
|
NonceStr: generateNonce(),
|
|
Method: "payment.preorder",
|
|
Version: "1.0",
|
|
BizContent: bizContent,
|
|
SignType: "SHA256WithRSA",
|
|
}
|
|
|
|
// Sign the request
|
|
signStr := canonicalSignString(preOrderPayloadToMap(requestPayload))
|
|
signature, err := signSHA256WithRSA(signStr, s.cfg.TELEBIRR.TelebirrAppSecret)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to sign request: %v", err)
|
|
}
|
|
requestPayload.Sign = signature
|
|
|
|
// Marshal to JSON
|
|
bodyBytes, _ := json.Marshal(requestPayload)
|
|
|
|
// Step 3: Make the request
|
|
req, _ := http.NewRequest("POST", s.cfg.TELEBIRR.TelebirrBaseURL+"/payment/v1/merchant/preOrder", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-APP-Key", s.cfg.TELEBIRR.TelebirrFabricAppID)
|
|
req.Header.Set("Authorization", fabricToken)
|
|
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("telebirr preOrder request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("telebirr preOrder failed: %s", string(body))
|
|
}
|
|
|
|
var response map[string]interface{}
|
|
if err := json.Unmarshal(body, &response); err != nil {
|
|
return "", fmt.Errorf("telebirr response parse error: %v", err)
|
|
}
|
|
|
|
biz := response["biz_content"].(map[string]interface{})
|
|
prepayID := biz["prepay_id"].(string)
|
|
|
|
// Step 4: Build checkout URL
|
|
checkoutURL, err := s.BuildTelebirrCheckoutURL(prepayID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
SenderWallets, err := s.walletSvc.GetWalletsByUser(req.Context(), userID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get user wallets: %v", err)
|
|
}
|
|
|
|
s.transferStore.CreateTransfer(req.Context(), domain.CreateTransfer{
|
|
Amount: domain.Currency(amount),
|
|
Verified: false,
|
|
Type: domain.DEPOSIT,
|
|
ReferenceNumber: orderID,
|
|
Status: string(domain.PaymentStatusPending),
|
|
SenderWalletID: domain.ValidInt64{
|
|
Value: SenderWallets[0].ID,
|
|
Valid: true,
|
|
},
|
|
Message: fmt.Sprintf("Telebirr order created with ID: %s and amount: %f", orderID, amount),
|
|
})
|
|
|
|
return checkoutURL, nil
|
|
}
|
|
|
|
func (s *TelebirrService) BuildTelebirrCheckoutURL(prepayID string) (string, error) {
|
|
|
|
// Convert params struct to map[string]string for signing
|
|
params := map[string]string{
|
|
"app_id": s.cfg.TELEBIRR.TelebirrFabricAppID,
|
|
"merch_code": s.cfg.TELEBIRR.TelebirrMerchantCode,
|
|
"nonce_str": generateNonce(),
|
|
"prepay_id": prepayID,
|
|
"timestamp": fmt.Sprintf("%d", time.Now().Unix()),
|
|
}
|
|
signStr := canonicalSignString(params)
|
|
signature, err := signSHA256WithRSA(signStr, s.cfg.TELEBIRR.TelebirrAppSecret)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to sign checkout URL: %v", err)
|
|
}
|
|
|
|
query := url.Values{}
|
|
for k, v := range params {
|
|
query.Set(k, v)
|
|
}
|
|
query.Set("sign", signature)
|
|
query.Set("sign_type", "SHA256WithRSA")
|
|
query.Set("version", "1.0")
|
|
query.Set("trade_type", "Checkout")
|
|
|
|
// Step 4: Build final URL
|
|
return s.cfg.TELEBIRR.TelebirrBaseURL + query.Encode(), nil
|
|
}
|
|
|
|
func (s *TelebirrService) HandleTelebirrPaymentCallback(ctx context.Context, payload *domain.TelebirrPaymentCallbackPayload) error {
|
|
|
|
transfer, err := s.transferStore.GetTransferByReference(ctx, payload.PaymentOrderID)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch transfer by reference: %w", err)
|
|
}
|
|
|
|
if transfer.Status != string(domain.PaymentStatusPending) {
|
|
return fmt.Errorf("payment not pending, status: %s", transfer.Status)
|
|
} else if transfer.Verified == true {
|
|
return fmt.Errorf("payment already verified")
|
|
}
|
|
|
|
if payload.TradeStatus != "Completed" {
|
|
return fmt.Errorf("payment not completed, status: %s", payload.TradeStatus)
|
|
}
|
|
|
|
// 1. Validate the signature
|
|
// if err := s.VerifyCallbackSignature(payload); err != nil {
|
|
// return fmt.Errorf("invalid callback signature: %w", err)
|
|
// }
|
|
|
|
if err := s.transferStore.UpdateTransferStatus(ctx, transfer.ID, string(domain.PaymentStatusCompleted)); err != nil {
|
|
return fmt.Errorf("failed to update transfer status: %w", err)
|
|
}
|
|
|
|
// 4. Parse amount
|
|
amount, err := strconv.ParseFloat(payload.TotalAmount, 64)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid amount format: %s", payload.TotalAmount)
|
|
}
|
|
_, err = s.walletSvc.AddToWallet(ctx, transfer.SenderWalletID.Value, domain.Currency(amount), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}, fmt.Sprintf("Added %v to wallet for Telebirr payment", amount))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add amount to wallet: %w", err)
|
|
}
|
|
|
|
if err := s.transferStore.UpdateTransferVerification(ctx, transfer.ID, true); err != nil {
|
|
return fmt.Errorf("failed to update transfer verification: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Verifies the RSA-SHA256 signature of the payload
|
|
// func (s *TelebirrService) VerifyCallbackSignature(payload *domain.TelebirrPaymentCallbackPayload) error {
|
|
// // 1. Extract the signature from the payload
|
|
// signatureBase64 := payload.Sign
|
|
// signType := payload.SignType
|
|
|
|
// if signType != "SHA256WithRSA" {
|
|
// return fmt.Errorf("unsupported sign_type: %s", signType)
|
|
// }
|
|
|
|
// // 2. Convert the payload to map (excluding 'sign' and 'sign_type')
|
|
// payloadMap := map[string]string{
|
|
// "notify_url": payload.NotifyURL,
|
|
// "appid": payload.AppID,
|
|
// "notify_time": payload.NotifyTime,
|
|
// "merch_code": payload.MerchCode,
|
|
// "merch_order_id": payload.MerchOrderID,
|
|
// "payment_order_id": payload.PaymentOrderID,
|
|
// "total_amount": payload.TotalAmount,
|
|
// "trans_id": payload.TransID,
|
|
// "trans_currency": payload.TransCurrency,
|
|
// "trade_status": payload.TradeStatus,
|
|
// "trans_end_time": payload.TransEndTime,
|
|
// }
|
|
|
|
// // 3. Sort the keys and build the canonical string
|
|
// var keys []string
|
|
// for k := range payloadMap {
|
|
// keys = append(keys, k)
|
|
// }
|
|
// sort.Strings(keys)
|
|
|
|
// var canonicalParts []string
|
|
// for _, k := range keys {
|
|
// canonicalParts = append(canonicalParts, fmt.Sprintf("%s=%s", k, payloadMap[k]))
|
|
// }
|
|
// canonicalString := strings.Join(canonicalParts, "&")
|
|
|
|
// // 4. Hash the canonical string
|
|
// hashed := sha256.Sum256([]byte(canonicalString))
|
|
|
|
// // 5. Decode the base64 signature
|
|
// signature, err := base64.StdEncoding.DecodeString(signatureBase64)
|
|
// if err != nil {
|
|
// return fmt.Errorf("failed to decode signature: %w", err)
|
|
// }
|
|
|
|
// // 6. Load the RSA public key (PEM format)
|
|
// pubKeyPEM := []byte(s.cfg.TELEBIRR.PublicKey) // Must be full PEM string
|
|
|
|
// block, _ := pem.Decode(pubKeyPEM)
|
|
// if block == nil || block.Type != "PUBLIC KEY" {
|
|
// return errors.New("invalid public key PEM block")
|
|
// }
|
|
|
|
// pubKeyInterface, err := x509.ParsePKIXPublicKey(block.Bytes)
|
|
// if err != nil {
|
|
// return fmt.Errorf("failed to parse RSA public key: %w", err)
|
|
// }
|
|
|
|
// rsaPubKey, ok := pubKeyInterface.(*rsa.PublicKey)
|
|
// if !ok {
|
|
// return errors.New("not a valid RSA public key")
|
|
// }
|
|
|
|
// // 7. Verify the signature
|
|
// err = rsa.VerifyPKCS1v15(rsaPubKey, crypto.SHA256, hashed[:], signature)
|
|
// if err != nil {
|
|
// return fmt.Errorf("RSA signature verification failed: %w", err)
|
|
// }
|
|
|
|
// return nil
|
|
// }
|
|
|
|
func generateNonce() string {
|
|
return fmt.Sprintf("telebirr%x", time.Now().UnixNano())
|
|
}
|
|
|
|
func canonicalSignString(data map[string]string) string {
|
|
keys := make([]string, 0, len(data))
|
|
for k := range data {
|
|
if k != "sign" && k != "sign_type" {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
var b strings.Builder
|
|
for i, k := range keys {
|
|
value := data[k]
|
|
var valStr string
|
|
if k == "biz_content" {
|
|
jsonVal, _ := json.Marshal(value)
|
|
valStr = string(jsonVal)
|
|
} else {
|
|
valStr = fmt.Sprintf("%v", value)
|
|
}
|
|
b.WriteString(fmt.Sprintf("%s=%s", k, valStr))
|
|
if i < len(keys)-1 {
|
|
b.WriteString("&")
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func signSHA256WithRSA(signStr, privateKeyPEM string) (string, error) {
|
|
block, _ := pem.Decode([]byte(privateKeyPEM))
|
|
if block == nil {
|
|
return "", fmt.Errorf("invalid PEM private key")
|
|
}
|
|
|
|
priv, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return "", fmt.Errorf("unable to parse private key: %v", err)
|
|
}
|
|
|
|
hashed := sha256.Sum256([]byte(signStr))
|
|
|
|
sig, err := rsa.SignPKCS1v15(rand.Reader, priv.(*rsa.PrivateKey), crypto.SHA256, hashed[:])
|
|
if err != nil {
|
|
return "", fmt.Errorf("signing failed: %v", err)
|
|
}
|
|
|
|
return base64.StdEncoding.EncodeToString(sig), nil
|
|
}
|
|
|
|
// Helper function to convert TelebirrPreOrderRequestPayload to map[string]string for signing
|
|
func preOrderPayloadToMap(payload domain.TelebirrPreOrderRequestPayload) map[string]string {
|
|
m := map[string]string{
|
|
"timestamp": payload.Timestamp,
|
|
"nonce_str": payload.NonceStr,
|
|
"method": payload.Method,
|
|
"version": payload.Version,
|
|
"sign_type": payload.SignType,
|
|
}
|
|
// BizContent needs to be marshaled as JSON string
|
|
bizContentBytes, _ := json.Marshal(payload.BizContent)
|
|
m["biz_content"] = string(bizContentBytes)
|
|
return m
|
|
}
|