createTransaction+createTransfer fix

This commit is contained in:
Yared Yemane 2025-05-31 21:29:39 +03:00
parent eb8abfc963
commit 75d469be8c
12 changed files with 1164 additions and 30 deletions

View File

@ -109,11 +109,15 @@ func main() {
logger, logger,
) )
recommendationSvc := recommendation.NewService(recommendationRepo) recommendationSvc := recommendation.NewService(recommendationRepo)
chapaClient := chapa.NewClient(cfg.CHAPA_BASE_URL, cfg.CHAPA_SECRET_KEY)
chapaSvc := chapa.NewService( chapaSvc := chapa.NewService(
transaction.TransactionStore(store), transaction.TransactionStore(store),
wallet.WalletStore(store), wallet.WalletStore(store),
user.UserStore(store), user.UserStore(store),
referalSvc, referalSvc,
branch.BranchStore(store),
chapaClient,
store, store,
) )

View File

@ -361,6 +361,63 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/chapa/payments/deposit": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Deposits money into user wallet from user account using Chapa",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Chapa"
],
"summary": "Deposit money into user wallet using Chapa",
"parameters": [
{
"description": "Deposit request payload",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.ChapaDepositRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.ChapaPaymentUrlResponseWrapper"
}
},
"400": {
"description": "Invalid request",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"422": {
"description": "Validation error",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/chapa/payments/initialize": { "/api/v1/chapa/payments/initialize": {
"post": { "post": {
"description": "Initiate a payment through Chapa", "description": "Initiate a payment through Chapa",
@ -395,6 +452,39 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/chapa/payments/verify": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Chapa"
],
"summary": "Verifies Chapa webhook transaction",
"parameters": [
{
"description": "Webhook Payload",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.ChapaTransactionType"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/chapa/payments/verify/{tx_ref}": { "/api/v1/chapa/payments/verify/{tx_ref}": {
"get": { "get": {
"description": "Verify the transaction status from Chapa using tx_ref", "description": "Verify the transaction status from Chapa using tx_ref",
@ -427,6 +517,76 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/chapa/payments/withdraw": {
"post": {
"description": "Initiates a withdrawal transaction using Chapa for the authenticated user.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Chapa"
],
"summary": "Withdraw using Chapa",
"parameters": [
{
"description": "Chapa Withdraw Request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.ChapaWithdrawRequest"
}
}
],
"responses": {
"200": {
"description": "Withdrawal requested successfully",
"schema": {
"allOf": [
{
"$ref": "#/definitions/domain.Response"
},
{
"type": "object",
"properties": {
"data": {
"type": "string"
}
}
}
]
}
},
"400": {
"description": "Invalid request",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"422": {
"description": "Unprocessable Entity",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/chapa/transfers": { "/api/v1/chapa/transfers": {
"post": { "post": {
"description": "Initiate a transfer request via Chapa", "description": "Initiate a transfer request via Chapa",
@ -4413,6 +4573,46 @@ const docTemplate = `{
} }
} }
}, },
"domain.ChapaDepositRequest": {
"type": "object",
"properties": {
"amount": {
"type": "integer"
},
"branch_id": {
"type": "integer"
},
"currency": {
"type": "string"
},
"phone_number": {
"type": "string"
}
}
},
"domain.ChapaPaymentUrlResponse": {
"type": "object",
"properties": {
"payment_url": {
"type": "string"
}
}
},
"domain.ChapaPaymentUrlResponseWrapper": {
"type": "object",
"properties": {
"data": {},
"message": {
"type": "string"
},
"status_code": {
"type": "integer"
},
"success": {
"type": "boolean"
}
}
},
"domain.ChapaSupportedBank": { "domain.ChapaSupportedBank": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -4480,6 +4680,44 @@ const docTemplate = `{
} }
} }
}, },
"domain.ChapaTransactionType": {
"type": "object",
"properties": {
"type": {
"type": "string"
}
}
},
"domain.ChapaWithdrawRequest": {
"type": "object",
"properties": {
"account_name": {
"type": "string"
},
"account_number": {
"type": "string"
},
"amount": {
"type": "integer"
},
"bank_code": {
"type": "string"
},
"beneficiary_name": {
"type": "string"
},
"branch_id": {
"type": "integer"
},
"currency": {
"type": "string"
},
"wallet_id": {
"description": "add this",
"type": "integer"
}
}
},
"domain.CreateBetOutcomeReq": { "domain.CreateBetOutcomeReq": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -4561,7 +4799,7 @@ const docTemplate = `{
"type": "object", "type": "object",
"properties": { "properties": {
"amount": { "amount": {
"type": "string" "type": "integer"
}, },
"callback_url": { "callback_url": {
"type": "string" "type": "string"
@ -4832,6 +5070,21 @@ const docTemplate = `{
} }
} }
}, },
"domain.Response": {
"type": "object",
"properties": {
"data": {},
"message": {
"type": "string"
},
"status_code": {
"type": "integer"
},
"success": {
"type": "boolean"
}
}
},
"domain.Role": { "domain.Role": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -5041,6 +5294,10 @@ const docTemplate = `{
"description": "Match or event name", "description": "Match or event name",
"type": "string" "type": "string"
}, },
"source": {
"description": "bet api provider (bet365, betfair)",
"type": "string"
},
"sportID": { "sportID": {
"description": "Sport ID", "description": "Sport ID",
"type": "string" "type": "string"

View File

@ -353,6 +353,63 @@
} }
} }
}, },
"/api/v1/chapa/payments/deposit": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Deposits money into user wallet from user account using Chapa",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Chapa"
],
"summary": "Deposit money into user wallet using Chapa",
"parameters": [
{
"description": "Deposit request payload",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.ChapaDepositRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.ChapaPaymentUrlResponseWrapper"
}
},
"400": {
"description": "Invalid request",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"422": {
"description": "Validation error",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/chapa/payments/initialize": { "/api/v1/chapa/payments/initialize": {
"post": { "post": {
"description": "Initiate a payment through Chapa", "description": "Initiate a payment through Chapa",
@ -387,6 +444,39 @@
} }
} }
}, },
"/api/v1/chapa/payments/verify": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Chapa"
],
"summary": "Verifies Chapa webhook transaction",
"parameters": [
{
"description": "Webhook Payload",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.ChapaTransactionType"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/chapa/payments/verify/{tx_ref}": { "/api/v1/chapa/payments/verify/{tx_ref}": {
"get": { "get": {
"description": "Verify the transaction status from Chapa using tx_ref", "description": "Verify the transaction status from Chapa using tx_ref",
@ -419,6 +509,76 @@
} }
} }
}, },
"/api/v1/chapa/payments/withdraw": {
"post": {
"description": "Initiates a withdrawal transaction using Chapa for the authenticated user.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Chapa"
],
"summary": "Withdraw using Chapa",
"parameters": [
{
"description": "Chapa Withdraw Request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.ChapaWithdrawRequest"
}
}
],
"responses": {
"200": {
"description": "Withdrawal requested successfully",
"schema": {
"allOf": [
{
"$ref": "#/definitions/domain.Response"
},
{
"type": "object",
"properties": {
"data": {
"type": "string"
}
}
}
]
}
},
"400": {
"description": "Invalid request",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"422": {
"description": "Unprocessable Entity",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/chapa/transfers": { "/api/v1/chapa/transfers": {
"post": { "post": {
"description": "Initiate a transfer request via Chapa", "description": "Initiate a transfer request via Chapa",
@ -4405,6 +4565,46 @@
} }
} }
}, },
"domain.ChapaDepositRequest": {
"type": "object",
"properties": {
"amount": {
"type": "integer"
},
"branch_id": {
"type": "integer"
},
"currency": {
"type": "string"
},
"phone_number": {
"type": "string"
}
}
},
"domain.ChapaPaymentUrlResponse": {
"type": "object",
"properties": {
"payment_url": {
"type": "string"
}
}
},
"domain.ChapaPaymentUrlResponseWrapper": {
"type": "object",
"properties": {
"data": {},
"message": {
"type": "string"
},
"status_code": {
"type": "integer"
},
"success": {
"type": "boolean"
}
}
},
"domain.ChapaSupportedBank": { "domain.ChapaSupportedBank": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -4472,6 +4672,44 @@
} }
} }
}, },
"domain.ChapaTransactionType": {
"type": "object",
"properties": {
"type": {
"type": "string"
}
}
},
"domain.ChapaWithdrawRequest": {
"type": "object",
"properties": {
"account_name": {
"type": "string"
},
"account_number": {
"type": "string"
},
"amount": {
"type": "integer"
},
"bank_code": {
"type": "string"
},
"beneficiary_name": {
"type": "string"
},
"branch_id": {
"type": "integer"
},
"currency": {
"type": "string"
},
"wallet_id": {
"description": "add this",
"type": "integer"
}
}
},
"domain.CreateBetOutcomeReq": { "domain.CreateBetOutcomeReq": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -4553,7 +4791,7 @@
"type": "object", "type": "object",
"properties": { "properties": {
"amount": { "amount": {
"type": "string" "type": "integer"
}, },
"callback_url": { "callback_url": {
"type": "string" "type": "string"
@ -4824,6 +5062,21 @@
} }
} }
}, },
"domain.Response": {
"type": "object",
"properties": {
"data": {},
"message": {
"type": "string"
},
"status_code": {
"type": "integer"
},
"success": {
"type": "boolean"
}
}
},
"domain.Role": { "domain.Role": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -5033,6 +5286,10 @@
"description": "Match or event name", "description": "Match or event name",
"type": "string" "type": "string"
}, },
"source": {
"description": "bet api provider (bet365, betfair)",
"type": "string"
},
"sportID": { "sportID": {
"description": "Sport ID", "description": "Sport ID",
"type": "string" "type": "string"

View File

@ -124,6 +124,32 @@ definitions:
example: 2 example: 2
type: integer type: integer
type: object type: object
domain.ChapaDepositRequest:
properties:
amount:
type: integer
branch_id:
type: integer
currency:
type: string
phone_number:
type: string
type: object
domain.ChapaPaymentUrlResponse:
properties:
payment_url:
type: string
type: object
domain.ChapaPaymentUrlResponseWrapper:
properties:
data: {}
message:
type: string
status_code:
type: integer
success:
type: boolean
type: object
domain.ChapaSupportedBank: domain.ChapaSupportedBank:
properties: properties:
acct_length: acct_length:
@ -168,6 +194,31 @@ definitions:
message: message:
type: string type: string
type: object type: object
domain.ChapaTransactionType:
properties:
type:
type: string
type: object
domain.ChapaWithdrawRequest:
properties:
account_name:
type: string
account_number:
type: string
amount:
type: integer
bank_code:
type: string
beneficiary_name:
type: string
branch_id:
type: integer
currency:
type: string
wallet_id:
description: add this
type: integer
type: object
domain.CreateBetOutcomeReq: domain.CreateBetOutcomeReq:
properties: properties:
event_id: event_id:
@ -222,7 +273,7 @@ definitions:
domain.InitPaymentRequest: domain.InitPaymentRequest:
properties: properties:
amount: amount:
type: string type: integer
callback_url: callback_url:
type: string type: string
currency: currency:
@ -408,6 +459,16 @@ definitions:
totalRewardEarned: totalRewardEarned:
type: number type: number
type: object type: object
domain.Response:
properties:
data: {}
message:
type: string
status_code:
type: integer
success:
type: boolean
type: object
domain.Role: domain.Role:
enum: enum:
- super_admin - super_admin
@ -555,6 +616,9 @@ definitions:
matchName: matchName:
description: Match or event name description: Match or event name
type: string type: string
source:
description: bet api provider (bet365, betfair)
type: string
sportID: sportID:
description: Sport ID description: Sport ID
type: string type: string
@ -1721,6 +1785,42 @@ paths:
summary: Receive Chapa webhook summary: Receive Chapa webhook
tags: tags:
- Chapa - Chapa
/api/v1/chapa/payments/deposit:
post:
consumes:
- application/json
description: Deposits money into user wallet from user account using Chapa
parameters:
- description: Deposit request payload
in: body
name: payload
required: true
schema:
$ref: '#/definitions/domain.ChapaDepositRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.ChapaPaymentUrlResponseWrapper'
"400":
description: Invalid request
schema:
$ref: '#/definitions/domain.Response'
"422":
description: Validation error
schema:
$ref: '#/definitions/domain.Response'
"500":
description: Internal server error
schema:
$ref: '#/definitions/domain.Response'
security:
- ApiKeyAuth: []
summary: Deposit money into user wallet using Chapa
tags:
- Chapa
/api/v1/chapa/payments/initialize: /api/v1/chapa/payments/initialize:
post: post:
consumes: consumes:
@ -1743,6 +1843,27 @@ paths:
summary: Initialize a payment transaction summary: Initialize a payment transaction
tags: tags:
- Chapa - Chapa
/api/v1/chapa/payments/verify:
post:
consumes:
- application/json
parameters:
- description: Webhook Payload
in: body
name: payload
required: true
schema:
$ref: '#/definitions/domain.ChapaTransactionType'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
summary: Verifies Chapa webhook transaction
tags:
- Chapa
/api/v1/chapa/payments/verify/{tx_ref}: /api/v1/chapa/payments/verify/{tx_ref}:
get: get:
consumes: consumes:
@ -1764,6 +1885,50 @@ paths:
summary: Verify a payment transaction summary: Verify a payment transaction
tags: tags:
- Chapa - Chapa
/api/v1/chapa/payments/withdraw:
post:
consumes:
- application/json
description: Initiates a withdrawal transaction using Chapa for the authenticated
user.
parameters:
- description: Chapa Withdraw Request
in: body
name: request
required: true
schema:
$ref: '#/definitions/domain.ChapaWithdrawRequest'
produces:
- application/json
responses:
"200":
description: Withdrawal requested successfully
schema:
allOf:
- $ref: '#/definitions/domain.Response'
- properties:
data:
type: string
type: object
"400":
description: Invalid request
schema:
$ref: '#/definitions/domain.Response'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/domain.Response'
"422":
description: Unprocessable Entity
schema:
$ref: '#/definitions/domain.Response'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.Response'
summary: Withdraw using Chapa
tags:
- Chapa
/api/v1/chapa/transfers: /api/v1/chapa/transfers:
post: post:
consumes: consumes:

View File

@ -1,6 +1,9 @@
package domain package domain
import "time" import (
"errors"
"time"
)
var ( var (
ChapaSecret string ChapaSecret string
@ -8,14 +11,14 @@ var (
) )
type InitPaymentRequest struct { type InitPaymentRequest struct {
Amount string `json:"amount"` Amount Currency `json:"amount"`
Currency string `json:"currency"` Currency string `json:"currency"`
Email string `json:"email"` Email string `json:"email"`
FirstName string `json:"first_name"` FirstName string `json:"first_name"`
LastName string `json:"last_name"` LastName string `json:"last_name"`
TxRef string `json:"tx_ref"` TxRef string `json:"tx_ref"`
CallbackURL string `json:"callback_url"` CallbackURL string `json:"callback_url"`
ReturnURL string `json:"return_url"` ReturnURL string `json:"return_url"`
} }
type TransferRequest struct { type TransferRequest struct {
@ -149,3 +152,72 @@ type ChapaWebHookPayment struct {
} `json:"customization"` } `json:"customization"`
Meta string `json:"meta"` Meta string `json:"meta"`
} }
type ChapaWithdrawRequest struct {
WalletID int64 `json:"wallet_id"` // add this
AccountName string `json:"account_name"`
AccountNumber string `json:"account_number"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
BeneficiaryName string `json:"beneficiary_name"`
BankCode string `json:"bank_code"`
BranchID int64 `json:"branch_id"`
}
type ChapaTransferPayload struct {
AccountName string
AccountNumber string
Amount string
Currency string
BeneficiaryName string
TxRef string
Reference string
BankCode string
}
type ChapaDepositRequest struct {
Amount Currency `json:"amount"`
PhoneNumber string `json:"phone_number"`
Currency string `json:"currency"`
BranchID int64 `json:"branch_id"`
}
func (r ChapaDepositRequest) Validate() error {
if r.Amount <= 0 {
return errors.New("amount must be greater than zero")
}
if r.Currency == "" {
return errors.New("currency is required")
}
if r.PhoneNumber == "" {
return errors.New("phone number is required")
}
if r.BranchID == 0 {
return errors.New("branch ID is required")
}
return nil
}
type AcceptChapaPaymentRequest struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
PhoneNumber string `json:"phone_number"`
TxRef string `json:"tx_ref"`
CallbackUrl string `json:"callback_url"`
ReturnUrl string `json:"return_url"`
CustomizationTitle string `json:"customization[title]"`
CustomizationDescription string `json:"customization[description]"`
}
type ChapaPaymentUrlResponse struct {
PaymentURL string `json:"payment_url"`
}
type ChapaPaymentUrlResponseWrapper struct {
Data ChapaPaymentUrlResponse `json:"data"`
Response
}

View File

@ -48,11 +48,14 @@ func (m Currency) String() string {
return fmt.Sprintf("$%.2f", x) return fmt.Sprintf("$%.2f", x)
} }
type Response struct { type ResponseWDataFactory[T any] struct {
Message string `json:"message"` Data T `json:"data"`
Data interface{} `json:"data,omitempty"` Response
Success bool `json:"success"`
StatusCode int `json:"status_code"`
} }
type Response struct {
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
Success bool `json:"success"`
StatusCode int `json:"status_code"`
}

View File

@ -57,7 +57,6 @@ type CreateTransaction struct {
PaymentOption PaymentOption PaymentOption PaymentOption
FullName string FullName string
PhoneNumber string PhoneNumber string
// Payment Details for bank
BankCode string BankCode string
BeneficiaryName string BeneficiaryName string
AccountName string AccountName string

View File

@ -0,0 +1,98 @@
package chapa
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
)
type ChapaClient interface {
IssuePayment(ctx context.Context, payload domain.ChapaTransferPayload) (bool, error)
InitPayment(ctx context.Context, req domain.InitPaymentRequest) (string, error)
}
type Client struct {
BaseURL string
SecretKey string
HTTPClient *http.Client
}
func NewClient(baseURL, secretKey string) *Client {
return &Client{
BaseURL: baseURL,
SecretKey: secretKey,
HTTPClient: http.DefaultClient,
}
}
func (c *Client) IssuePayment(ctx context.Context, payload domain.ChapaTransferPayload) (bool, error) {
payloadBytes, err := json.Marshal(payload)
if err != nil {
return false, fmt.Errorf("failed to serialize payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/transfers", bytes.NewBuffer(payloadBytes))
if err != nil {
return false, fmt.Errorf("failed to create HTTP request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.SecretKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return false, fmt.Errorf("chapa HTTP request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return true, nil
}
return false, fmt.Errorf("chapa error: status %d, body: %s", resp.StatusCode, string(body))
}
// service/chapa_service.go
func (c *Client) InitPayment(ctx context.Context, req domain.InitPaymentRequest) (string, error) {
payloadBytes, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("failed to serialize payload: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/transaction/initialize", bytes.NewBuffer(payloadBytes))
if err != nil {
return "", fmt.Errorf("failed to create HTTP request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+c.SecretKey)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return "", fmt.Errorf("chapa HTTP request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("chapa error: status %d, body: %s", resp.StatusCode, string(body))
}
var response struct {
Data struct {
CheckoutURL string `json:"checkout_url"`
} `json:"data"`
}
if err := json.Unmarshal(body, &response); err != nil {
return "", fmt.Errorf("failed to parse chapa response: %w", err)
}
return response.Data.CheckoutURL, nil
}

View File

@ -9,4 +9,6 @@ import (
type ChapaPort interface { type ChapaPort interface {
HandleChapaTransferWebhook(ctx context.Context, req domain.ChapaWebHookTransfer) error HandleChapaTransferWebhook(ctx context.Context, req domain.ChapaWebHookTransfer) error
HandleChapaPaymentWebhook(ctx context.Context, req domain.ChapaWebHookPayment) error HandleChapaPaymentWebhook(ctx context.Context, req domain.ChapaWebHookPayment) error
WithdrawUsingChapa(ctx context.Context, userID int64, req domain.ChapaWithdrawRequest) error
DepositUsingChapa(ctx context.Context, userID int64, req domain.ChapaDepositRequest) (string, error)
} }

View File

@ -2,15 +2,22 @@ package chapa
import ( import (
"context" "context"
"database/sql"
"errors"
"fmt" "fmt"
// "log/slog"
"strconv" "strconv"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/repository" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch"
referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal" referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
"github.com/google/uuid"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
@ -19,7 +26,11 @@ type Service struct {
walletStore wallet.WalletStore walletStore wallet.WalletStore
userStore user.UserStore userStore user.UserStore
referralStore referralservice.ReferralStore referralStore referralservice.ReferralStore
store *repository.Store branchStore branch.BranchStore
chapaClient ChapaClient
config *config.Config
// logger *slog.Logger
store *repository.Store
} }
func NewService( func NewService(
@ -27,6 +38,8 @@ func NewService(
walletStore wallet.WalletStore, walletStore wallet.WalletStore,
userStore user.UserStore, userStore user.UserStore,
referralStore referralservice.ReferralStore, referralStore referralservice.ReferralStore,
branchStore branch.BranchStore,
chapaClient ChapaClient,
store *repository.Store, store *repository.Store,
) *Service { ) *Service {
return &Service{ return &Service{
@ -34,6 +47,8 @@ func NewService(
walletStore: walletStore, walletStore: walletStore,
userStore: userStore, userStore: userStore,
referralStore: referralStore, referralStore: referralStore,
branchStore: branchStore,
chapaClient: chapaClient,
store: store, store: store,
} }
} }
@ -53,6 +68,9 @@ func (s *Service) HandleChapaTransferWebhook(ctx context.Context, req domain.Cha
txn, err := s.transactionStore.GetTransactionByID(ctx, referenceID) txn, err := s.transactionStore.GetTransactionByID(ctx, referenceID)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("transaction with ID %d not found", referenceID)
}
return err return err
} }
if txn.Verified { if txn.Verified {
@ -93,6 +111,9 @@ func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.Chap
// 2. Fetch transaction // 2. Fetch transaction
txn, err := s.transactionStore.GetTransactionByID(ctx, referenceID) txn, err := s.transactionStore.GetTransactionByID(ctx, referenceID)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("transaction with ID %d not found", referenceID)
}
return err return err
} }
if txn.Verified { if txn.Verified {
@ -122,7 +143,7 @@ func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.Chap
} }
// 7. Check & Create Referral // 7. Check & Create Referral
stats, err := s.referralStore.GetReferralStats(ctx, string(wallet.UserID)) stats, err := s.referralStore.GetReferralStats(ctx, strconv.FormatInt(wallet.UserID, 10))
if err != nil { if err != nil {
return err return err
} }
@ -135,3 +156,162 @@ func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.Chap
return tx.Commit(ctx) return tx.Commit(ctx)
} }
func (s *Service) WithdrawUsingChapa(ctx context.Context, userID int64, req domain.ChapaWithdrawRequest) error {
_, tx, err := s.store.BeginTx(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
// Get the requesting user
user, err := s.userStore.GetUserByID(ctx, userID)
if err != nil {
return fmt.Errorf("user not found: %w", err)
}
branch, err := s.branchStore.GetBranchByID(ctx, req.BranchID)
if err != nil {
return err
}
wallets, err := s.walletStore.GetWalletsByUser(ctx, userID)
if err != nil {
return err
}
var targetWallet *domain.Wallet
for _, w := range wallets {
if w.ID == req.WalletID {
targetWallet = &w
break
}
}
if targetWallet == nil {
return fmt.Errorf("no wallet found with the specified ID")
}
if !targetWallet.IsWithdraw || !targetWallet.IsActive {
return fmt.Errorf("wallet not eligible for withdrawal")
}
if targetWallet.Balance < domain.Currency(req.Amount) {
return fmt.Errorf("insufficient balance")
}
txID := uuid.New().String()
payload := domain.ChapaTransferPayload{
AccountName: req.AccountName,
AccountNumber: req.AccountNumber,
Amount: strconv.FormatInt(req.Amount, 10),
Currency: req.Currency,
BeneficiaryName: req.BeneficiaryName,
TxRef: txID,
Reference: txID,
BankCode: req.BankCode,
}
ok, err := s.chapaClient.IssuePayment(ctx, payload)
if err != nil || !ok {
return fmt.Errorf("chapa transfer failed: %v", err)
}
// Create transaction using user and wallet info
_, err = s.transactionStore.CreateTransaction(ctx, domain.CreateTransaction{
Amount: domain.Currency(req.Amount),
Type: domain.TransactionType(domain.TRANSACTION_CASHOUT),
ReferenceNumber: txID,
AccountName: req.AccountName,
AccountNumber: req.AccountNumber,
BankCode: req.BankCode,
BeneficiaryName: req.BeneficiaryName,
PaymentOption: domain.PaymentOption(domain.BANK),
BranchID: req.BranchID,
BranchName: branch.Name,
BranchLocation: branch.Location,
// CashierID: user.ID,
// CashierName: user.FullName,
FullName: user.FirstName + " " + user.LastName,
PhoneNumber: user.PhoneNumber,
CompanyID: branch.CompanyID,
})
if err != nil {
return fmt.Errorf("failed to create transaction: %w", err)
}
newBalance := domain.Currency(req.Amount)
err = s.walletStore.UpdateBalance(ctx, targetWallet.ID, newBalance)
if err != nil {
return fmt.Errorf("failed to update wallet balance: %w", err)
}
return tx.Commit(ctx)
}
func (s *Service) DepositUsingChapa(ctx context.Context, userID int64, req domain.ChapaDepositRequest) (string, error) {
_, tx, err := s.store.BeginTx(ctx)
if err != nil {
return "", err
}
defer tx.Rollback(ctx)
user, err := s.userStore.GetUserByID(ctx, userID)
if err != nil {
return "", err
}
branch, err := s.branchStore.GetBranchByID(ctx, req.BranchID)
if err != nil {
return "", err
}
txID := uuid.New().String()
_, err = s.transactionStore.CreateTransaction(ctx, domain.CreateTransaction{
Amount: req.Amount,
Type: domain.TransactionType(domain.TRANSACTION_DEPOSIT),
ReferenceNumber: txID,
BranchID: req.BranchID,
BranchName: branch.Name,
BranchLocation: branch.Location,
FullName: user.FirstName + " " + user.LastName,
PhoneNumber: user.PhoneNumber,
CompanyID: branch.CompanyID,
})
if err != nil {
return "", err
}
// Fetch user details for Chapa payment
userInfo, err := s.userStore.GetUserByID(ctx, userID)
if err != nil {
return "", err
}
// Build Chapa InitPaymentRequest (matches Chapa API)
paymentReq := domain.InitPaymentRequest{
Amount: req.Amount,
Currency: req.Currency,
Email: userInfo.Email,
FirstName: userInfo.FirstName,
LastName: userInfo.LastName,
TxRef: txID,
CallbackURL: s.config.CHAPA_CALLBACK_URL,
ReturnURL: s.config.CHAPA_RETURN_URL,
}
// Call Chapa to initialize payment
paymentURL, err := s.chapaClient.InitPayment(ctx, paymentReq)
if err != nil {
return "", err
}
// Commit DB transaction
if err := tx.Commit(ctx); err != nil {
return "", err
}
return paymentURL, nil
}

View File

@ -7,7 +7,6 @@ import (
"io" "io"
"net/http" "net/http"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/google/uuid" "github.com/google/uuid"
@ -299,7 +298,7 @@ func (h *Handler) VerifyChapaPayment(c *fiber.Ctx) error {
} }
switch txType.Type { switch txType.Type {
case config.ChapaConfig.ChapaTransferType: case "Payout":
var payload domain.ChapaWebHookTransfer var payload domain.ChapaWebHookTransfer
if err := c.BodyParser(&payload); err != nil { if err := c.BodyParser(&payload); err != nil {
return domain.UnProcessableEntityResponse(c) return domain.UnProcessableEntityResponse(c)
@ -315,7 +314,7 @@ func (h *Handler) VerifyChapaPayment(c *fiber.Ctx) error {
StatusCode: fiber.StatusOK, StatusCode: fiber.StatusOK,
}) })
case config.ChapaConfig.ChapaPaymentType: case "API":
var payload domain.ChapaWebHookPayment var payload domain.ChapaWebHookPayment
if err := c.BodyParser(&payload); err != nil { if err := c.BodyParser(&payload); err != nil {
return domain.UnProcessableEntityResponse(c) return domain.UnProcessableEntityResponse(c)
@ -339,3 +338,98 @@ func (h *Handler) VerifyChapaPayment(c *fiber.Ctx) error {
}) })
} }
} }
// WithdrawUsingChapa godoc
// @Summary Withdraw using Chapa
// @Description Initiates a withdrawal transaction using Chapa for the authenticated user.
// @Tags Chapa
// @Accept json
// @Produce json
// @Param request body domain.ChapaWithdrawRequest true "Chapa Withdraw Request"
// @Success 200 {object} domain.Response{data=string} "Withdrawal requested successfully"
// @Failure 400 {object} domain.Response "Invalid request"
// @Failure 401 {object} domain.Response "Unauthorized"
// @Failure 422 {object} domain.Response "Unprocessable Entity"
// @Failure 500 {object} domain.Response "Internal Server Error"
// @Router /api/v1/chapa/payments/withdraw [post]
func (h *Handler) WithdrawUsingChapa(c *fiber.Ctx) error {
var req domain.ChapaWithdrawRequest
if err := c.BodyParser(&req); err != nil {
return domain.UnProcessableEntityResponse(c)
}
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
return c.Status(fiber.StatusUnauthorized).JSON(domain.Response{
Message: "Unauthorized",
Success: false,
StatusCode: fiber.StatusUnauthorized,
})
}
if err := h.chapaSvc.WithdrawUsingChapa(c.Context(), userID, req); err != nil {
return domain.FiberErrorResponse(c, err)
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Withdrawal requested successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}
// DepositUsingChapa godoc
// @Summary Deposit money into user wallet using Chapa
// @Description Deposits money into user wallet from user account using Chapa
// @Tags Chapa
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param payload body domain.ChapaDepositRequest true "Deposit request payload"
// @Success 200 {object} domain.ChapaPaymentUrlResponseWrapper
// @Failure 400 {object} domain.Response "Invalid request"
// @Failure 422 {object} domain.Response "Validation error"
// @Failure 500 {object} domain.Response "Internal server error"
// @Router /api/v1/chapa/payments/deposit [post]
func (h *Handler) DepositUsingChapa(c *fiber.Ctx) error {
// Extract user info from token (adjust as per your auth middleware)
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
return c.Status(fiber.StatusUnauthorized).JSON(domain.Response{
Message: "Unauthorized",
Success: false,
StatusCode: fiber.StatusUnauthorized,
})
}
var req domain.ChapaDepositRequest
if err := c.BodyParser(&req); err != nil {
return domain.UnProcessableEntityResponse(c)
}
// Validate input in domain/model (you may have a Validate method)
if err := req.Validate(); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.Response{
Message: err.Error(),
Success: false,
StatusCode: fiber.StatusBadRequest,
})
}
// Call service to handle the deposit logic and get payment URL
paymentUrl, svcErr := h.chapaSvc.DepositUsingChapa(c.Context(), userID, req)
if svcErr != nil {
return domain.FiberErrorResponse(c, svcErr)
}
return c.Status(fiber.StatusOK).JSON(domain.ResponseWDataFactory[domain.ChapaPaymentUrlResponse]{
Data: domain.ChapaPaymentUrlResponse{
PaymentURL: paymentUrl,
},
Response: domain.Response{
Message: "Deposit process started on wallet, fulfill payment using the URL provided",
Success: true,
StatusCode: fiber.StatusOK,
},
})
}

View File

@ -182,13 +182,16 @@ func (a *App) initAppRoutes() {
a.fiber.Post("/transfer/refill/:id", a.authMiddleware, h.RefillWallet) a.fiber.Post("/transfer/refill/:id", a.authMiddleware, h.RefillWallet)
//Chapa Routes //Chapa Routes
group.Post("/chapa/payments/verify", h.VerifyChapaPayment)
group.Post("/chapa/payments/withdraw", h.WithdrawUsingChapa)
group.Post("/chapa/payments/deposit", h.DepositUsingChapa)
group.Post("/chapa/payments/initialize", h.InitializePayment) // group.Post("/chapa/payments/initialize", h.InitializePayment)
group.Get("/chapa/payments/verify/:tx_ref", h.VerifyTransaction) // group.Get("/chapa/payments/verify/:tx_ref", h.VerifyTransaction)
group.Post("/chapa/payments/callback", h.ReceiveWebhook) // group.Post("/chapa/payments/callback", h.ReceiveWebhook)
group.Get("/chapa/banks", h.GetBanks) // group.Get("/chapa/banks", h.GetBanks)
group.Post("/chapa/transfers", h.CreateTransfer) // group.Post("/chapa/transfers", h.CreateTransfer)
group.Get("/chapa/transfers/verify/:transfer_ref", h.VerifyTransfer) // group.Get("/chapa/transfers/verify/:transfer_ref", h.VerifyTransfer)
//Alea Play Virtual Game Routes //Alea Play Virtual Game Routes
group.Get("/alea-play/launch", a.authMiddleware, h.LaunchAleaGame) group.Get("/alea-play/launch", a.authMiddleware, h.LaunchAleaGame)