Yimaru-BackEnd/internal/services/santimpay/service.go

416 lines
13 KiB
Go

package santimpay
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
"github.com/google/uuid"
)
// type SantimPayService interface {
// GeneratePaymentURL(req domain.GeneratePaymentURLreq) (map[string]string, error)
// }
type SantimPayService struct {
client SantimPayClient
cfg *config.Config
transferStore wallet.TransferStore
walletSvc *wallet.Service
}
func NewSantimPayService(client SantimPayClient, cfg *config.Config, transferStore wallet.TransferStore, walletSvc *wallet.Service) *SantimPayService {
return &SantimPayService{
client: client,
cfg: cfg,
transferStore: transferStore,
walletSvc: walletSvc,
}
}
func (s *SantimPayService) InitiatePayment(req domain.GeneratePaymentURLRequest) (map[string]string, error) {
paymentID := uuid.NewString()
tokenPayload := domain.SantimTokenPayload{
Amount: req.Amount,
Reason: req.Reason,
}
// 1. Generate signed token (used as Bearer token in headers)
token, err := s.client.GenerateSignedToken(tokenPayload)
if err != nil {
return nil, fmt.Errorf("token generation failed: %w", err)
}
// 2. Prepare payload (without token in body)
payload := domain.InitiatePaymentRequest{
ID: paymentID,
Amount: req.Amount,
Reason: req.Reason,
MerchantID: s.cfg.SANTIMPAY.MerchantID,
SuccessRedirectURL: s.cfg.SANTIMPAY.SuccessUrl,
FailureRedirectURL: s.cfg.SANTIMPAY.CancelUrl,
NotifyURL: s.cfg.SANTIMPAY.NotifyURL,
CancelRedirectURL: s.cfg.SANTIMPAY.CancelUrl,
PhoneNumber: req.PhoneNumber,
SignedToken: token,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal payload: %w", err)
}
// 3. Prepare request with Bearer token header
httpReq, err := http.NewRequest("POST", s.cfg.SANTIMPAY.BaseURL+"/gateway/initiate-payment", bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to send HTTP request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("non-200 status code received: %d", resp.StatusCode)
}
var responseBody map[string]string
if err := json.NewDecoder(resp.Body).Decode(&responseBody); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
// 4. Save transfer
transfer := domain.CreateTransfer{
Amount: domain.Currency(req.Amount),
Verified: false,
Type: domain.DEPOSIT,
ReferenceNumber: paymentID,
Status: string(domain.PaymentStatusPending),
}
if _, err := s.transferStore.CreateTransfer(context.Background(), transfer); err != nil {
return nil, fmt.Errorf("failed to create transfer: %w", err)
}
// 5. Optionally check transaction status asynchronously
// go s.client.CheckTransactionStatus(paymentID)
return responseBody, nil
}
func (s *SantimPayService) ProcessCallback(ctx context.Context, payload domain.SantimPayCallbackPayload) error {
// 1. Parse amount
amount, err := strconv.ParseFloat(payload.Amount, 64)
if err != nil {
return fmt.Errorf("invalid amount in callback: %w", err)
}
// 2. Retrieve the corresponding transfer by txnId or refId
transfer, err := s.transferStore.GetTransferByReference(ctx, payload.TxnId)
if err != nil {
return fmt.Errorf("failed to fetch transfer for txnId %s: %w", payload.TxnId, err)
}
// 3. Update transfer status based on callback status
switch payload.Status {
case "COMPLETED":
transfer.Status = string(domain.PaymentStatusSuccessful)
transfer.Verified = true
userID, err := strconv.ParseInt(payload.ThirdPartyId, 10, 64)
if err != nil {
return fmt.Errorf("invalid ThirdPartyId '%s': %w", payload.ThirdPartyId, err)
}
wallets, err := s.walletSvc.GetWalletsByUser(ctx, userID)
if err != nil {
return fmt.Errorf("failed to get wallets for user %d: %w", userID, err)
}
// Optionally, credit user wallet
if transfer.Type == domain.DEPOSIT {
if _, err := s.walletSvc.AddToWallet(
ctx,
wallets[0].ID,
domain.Currency(amount),
domain.ValidInt64{},
domain.TRANSFER_SANTIMPAY,
domain.PaymentDetails{
ReferenceNumber: domain.ValidString{
Value: payload.TxnId,
Valid: true,
},
BankNumber: domain.ValidString{},
},
"",
); err != nil {
return fmt.Errorf("failed to credit wallet: %w", err)
}
}
case "FAILED", "CANCELLED":
transfer.Status = string(domain.PaymentStatusFailed)
transfer.Verified = false
default:
// Unknown status
return fmt.Errorf("unknown callback status: %s", payload.Status)
}
// 4. Save the updated transfer
if err := s.transferStore.UpdateTransferStatus(ctx, transfer.ID, string(domain.PaymentStatusCompleted)); err != nil {
return fmt.Errorf("failed to update transfer status: %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
}
func (s *SantimPayService) ProcessDirectPayment(ctx context.Context, req domain.GeneratePaymentURLRequest) (map[string]any, error) {
paymentID := uuid.NewString()
tokenPayload := domain.SantimTokenPayload{
Amount: req.Amount,
Reason: req.Reason,
PaymentMethod: req.PaymentMethod,
PhoneNumber: req.PhoneNumber,
}
// 1. Generate signed token for direct payment
token, err := s.client.GenerateSignedToken(tokenPayload)
if err != nil {
return nil, fmt.Errorf("failed to generate signed token: %w", err)
}
// 2. Build payload
payload := domain.InitiatePaymentRequest{
ID: paymentID,
Amount: req.Amount,
Reason: req.Reason,
MerchantID: s.cfg.SANTIMPAY.MerchantID,
SignedToken: token,
PhoneNumber: req.PhoneNumber,
NotifyURL: s.cfg.SANTIMPAY.NotifyURL,
PaymentMethod: req.PaymentMethod,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal payload: %w", err)
}
// 3. Prepare HTTP request
httpReq, err := http.NewRequest("POST", s.cfg.SANTIMPAY.BaseURL+"/direct-payment", bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to send HTTP request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("non-200 status code received: %d", resp.StatusCode)
}
// 4. Decode response
var responseBody map[string]any
if err := json.NewDecoder(resp.Body).Decode(&responseBody); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
// 5. Save transfer in DB
transfer := domain.CreateTransfer{
Amount: domain.Currency(req.Amount),
Verified: false,
Type: domain.DEPOSIT,
ReferenceNumber: paymentID,
Status: string(domain.PaymentStatusPending),
}
if _, err := s.transferStore.CreateTransfer(context.Background(), transfer); err != nil {
return nil, fmt.Errorf("failed to create transfer: %w", err)
}
// 6. Optionally check transaction status async
// go s.client.CheckTransactionStatus(paymentID)
return responseBody, nil
}
func (s *SantimPayService) GetB2CPartners(ctx context.Context) (*domain.B2CPartnersResponse, error) {
url := fmt.Sprintf("%s/api/v1/gateway/payout/partners", s.cfg.SANTIMPAY.BaseURL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
HTTPClient := &http.Client{Timeout: 15 * time.Second}
resp, err := HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call SantimPay API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var partnersResp domain.B2CPartnersResponse
if err := json.NewDecoder(resp.Body).Decode(&partnersResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &partnersResp, nil
}
func (s *SantimPayService) ProcessB2CWithdrawal(ctx context.Context, req domain.GeneratePaymentURLRequest, userId int64) (map[string]any, error) {
transactID := uuid.NewString()
// 1. Generate signed token for B2C
tokenPayload := domain.SantimTokenPayload{
Amount: req.Amount,
Reason: req.Reason,
PaymentMethod: req.PaymentMethod,
PhoneNumber: req.PhoneNumber,
}
signedToken, err := s.client.GenerateSignedToken(tokenPayload)
if err != nil {
return nil, fmt.Errorf("failed to generate signed token for B2C: %w", err)
}
// 2. Build payload
payload := domain.SantimpayB2CWithdrawalRequest{
ID: transactID,
ClientReference: string(rune(userId)),
Amount: float64(req.Amount),
Reason: req.Reason,
MerchantID: s.cfg.SANTIMPAY.MerchantID,
SignedToken: signedToken,
ReceiverAccountNumber: req.PhoneNumber,
NotifyURL: s.cfg.SANTIMPAY.NotifyURL,
PaymentMethod: req.PaymentMethod,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal B2C payload: %w", err)
}
// 3. Send HTTP request
url := s.cfg.SANTIMPAY.BaseURL + "/payout-transfer"
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create B2C request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+signedToken)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to send B2C request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("B2C request failed with status code: %d", resp.StatusCode)
}
// 4. Decode response
var responseBody map[string]any
if err := json.NewDecoder(resp.Body).Decode(&responseBody); err != nil {
return nil, fmt.Errorf("failed to decode B2C response: %w", err)
}
// 5. Persist withdrawal record in DB
withdrawal := domain.CreateTransfer{
Amount: domain.Currency(req.Amount),
Verified: false,
Type: domain.WITHDRAW,
ReferenceNumber: transactID,
Status: string(domain.PaymentStatusPending),
}
if _, err := s.transferStore.CreateTransfer(context.Background(), withdrawal); err != nil {
return nil, fmt.Errorf("failed to create withdrawal transfer: %w", err)
}
return responseBody, nil
}
func (s *SantimPayService) CheckTransactionStatus(ctx context.Context, req domain.TransactionStatusRequest) (map[string]any, error) {
// 1. Generate signed token for status check
tokenPayload := domain.SantimTokenPayload{
ID: req.TransactionID,
}
signedToken, err := s.client.GenerateSignedToken(tokenPayload)
if err != nil {
return nil, fmt.Errorf("failed to generate signed token for transaction status: %w", err)
}
// 2. Build request payload
payload := map[string]any{
"id": req.TransactionID,
"merchantId": s.cfg.SANTIMPAY.MerchantID,
"signedToken": signedToken,
"fullParams": req.FullParams,
"generated": time.Now().Unix(),
}
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal transaction status payload: %w", err)
}
// 3. Send HTTP request
url := s.cfg.SANTIMPAY.BaseURL + "/fetch-transaction-status"
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create transaction status request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+signedToken)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to send transaction status request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("transaction status request failed with status code: %d", resp.StatusCode)
}
// 4. Decode response
var responseBody map[string]any
if err := json.NewDecoder(resp.Body).Decode(&responseBody); err != nil {
return nil, fmt.Errorf("failed to decode transaction status response: %w", err)
}
return responseBody, nil
}