417 lines
13 KiB
Go
417 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/ports"
|
|
"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 ports.TransferStore
|
|
walletSvc *wallet.Service
|
|
}
|
|
|
|
func NewSantimPayService(client SantimPayClient, cfg *config.Config, transferStore ports.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
|
|
}
|