From 75d469be8cbca9b96768b6da1202281b5f42a970 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Sat, 31 May 2025 21:29:39 +0300 Subject: [PATCH] createTransaction+createTransfer fix --- cmd/main.go | 4 + docs/docs.go | 259 +++++++++++++++++++++++++- docs/swagger.json | 259 +++++++++++++++++++++++++- docs/swagger.yaml | 167 ++++++++++++++++- internal/domain/chapa.go | 90 ++++++++- internal/domain/common.go | 15 +- internal/domain/transaction.go | 1 - internal/services/chapa/client.go | 98 ++++++++++ internal/services/chapa/port.go | 2 + internal/services/chapa/service.go | 184 +++++++++++++++++- internal/web_server/handlers/chapa.go | 100 +++++++++- internal/web_server/routes.go | 15 +- 12 files changed, 1164 insertions(+), 30 deletions(-) create mode 100644 internal/services/chapa/client.go diff --git a/cmd/main.go b/cmd/main.go index d643d66..cd98778 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -109,11 +109,15 @@ func main() { logger, ) recommendationSvc := recommendation.NewService(recommendationRepo) + chapaClient := chapa.NewClient(cfg.CHAPA_BASE_URL, cfg.CHAPA_SECRET_KEY) + chapaSvc := chapa.NewService( transaction.TransactionStore(store), wallet.WalletStore(store), user.UserStore(store), referalSvc, + branch.BranchStore(store), + chapaClient, store, ) diff --git a/docs/docs.go b/docs/docs.go index 68e448c..044c114 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": { "post": { "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}": { "get": { "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": { "post": { "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": { "type": "object", "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": { "type": "object", "properties": { @@ -4561,7 +4799,7 @@ const docTemplate = `{ "type": "object", "properties": { "amount": { - "type": "string" + "type": "integer" }, "callback_url": { "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": { "type": "string", "enum": [ @@ -5041,6 +5294,10 @@ const docTemplate = `{ "description": "Match or event name", "type": "string" }, + "source": { + "description": "bet api provider (bet365, betfair)", + "type": "string" + }, "sportID": { "description": "Sport ID", "type": "string" diff --git a/docs/swagger.json b/docs/swagger.json index 850af3a..d225501 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": { "post": { "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}": { "get": { "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": { "post": { "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": { "type": "object", "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": { "type": "object", "properties": { @@ -4553,7 +4791,7 @@ "type": "object", "properties": { "amount": { - "type": "string" + "type": "integer" }, "callback_url": { "type": "string" @@ -4824,6 +5062,21 @@ } } }, + "domain.Response": { + "type": "object", + "properties": { + "data": {}, + "message": { + "type": "string" + }, + "status_code": { + "type": "integer" + }, + "success": { + "type": "boolean" + } + } + }, "domain.Role": { "type": "string", "enum": [ @@ -5033,6 +5286,10 @@ "description": "Match or event name", "type": "string" }, + "source": { + "description": "bet api provider (bet365, betfair)", + "type": "string" + }, "sportID": { "description": "Sport ID", "type": "string" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b698a18..a0003a7 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -124,6 +124,32 @@ definitions: example: 2 type: integer 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: properties: acct_length: @@ -168,6 +194,31 @@ definitions: message: type: string 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: properties: event_id: @@ -222,7 +273,7 @@ definitions: domain.InitPaymentRequest: properties: amount: - type: string + type: integer callback_url: type: string currency: @@ -408,6 +459,16 @@ definitions: totalRewardEarned: type: number type: object + domain.Response: + properties: + data: {} + message: + type: string + status_code: + type: integer + success: + type: boolean + type: object domain.Role: enum: - super_admin @@ -555,6 +616,9 @@ definitions: matchName: description: Match or event name type: string + source: + description: bet api provider (bet365, betfair) + type: string sportID: description: Sport ID type: string @@ -1721,6 +1785,42 @@ paths: summary: Receive Chapa webhook tags: - 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: post: consumes: @@ -1743,6 +1843,27 @@ paths: summary: Initialize a payment transaction tags: - 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}: get: consumes: @@ -1764,6 +1885,50 @@ paths: summary: Verify a payment transaction tags: - 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: post: consumes: diff --git a/internal/domain/chapa.go b/internal/domain/chapa.go index 885f6ad..4bd6d90 100644 --- a/internal/domain/chapa.go +++ b/internal/domain/chapa.go @@ -1,6 +1,9 @@ package domain -import "time" +import ( + "errors" + "time" +) var ( ChapaSecret string @@ -8,14 +11,14 @@ var ( ) type InitPaymentRequest struct { - Amount string `json:"amount"` - Currency string `json:"currency"` - Email string `json:"email"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - TxRef string `json:"tx_ref"` - CallbackURL string `json:"callback_url"` - ReturnURL string `json:"return_url"` + Amount Currency `json:"amount"` + Currency string `json:"currency"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + TxRef string `json:"tx_ref"` + CallbackURL string `json:"callback_url"` + ReturnURL string `json:"return_url"` } type TransferRequest struct { @@ -149,3 +152,72 @@ type ChapaWebHookPayment struct { } `json:"customization"` 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 +} diff --git a/internal/domain/common.go b/internal/domain/common.go index 6e0acbb..fcccacb 100644 --- a/internal/domain/common.go +++ b/internal/domain/common.go @@ -48,11 +48,14 @@ func (m Currency) String() string { return fmt.Sprintf("$%.2f", x) } -type Response struct { - Message string `json:"message"` - Data interface{} `json:"data,omitempty"` - Success bool `json:"success"` - StatusCode int `json:"status_code"` +type ResponseWDataFactory[T any] struct { + Data T `json:"data"` + Response } - +type Response struct { + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` + Success bool `json:"success"` + StatusCode int `json:"status_code"` +} diff --git a/internal/domain/transaction.go b/internal/domain/transaction.go index d767600..6e4668e 100644 --- a/internal/domain/transaction.go +++ b/internal/domain/transaction.go @@ -57,7 +57,6 @@ type CreateTransaction struct { PaymentOption PaymentOption FullName string PhoneNumber string - // Payment Details for bank BankCode string BeneficiaryName string AccountName string diff --git a/internal/services/chapa/client.go b/internal/services/chapa/client.go new file mode 100644 index 0000000..ff5a888 --- /dev/null +++ b/internal/services/chapa/client.go @@ -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 +} diff --git a/internal/services/chapa/port.go b/internal/services/chapa/port.go index 57ca589..b1b181f 100644 --- a/internal/services/chapa/port.go +++ b/internal/services/chapa/port.go @@ -9,4 +9,6 @@ import ( type ChapaPort interface { HandleChapaTransferWebhook(ctx context.Context, req domain.ChapaWebHookTransfer) 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) } diff --git a/internal/services/chapa/service.go b/internal/services/chapa/service.go index ea7915a..69d5809 100644 --- a/internal/services/chapa/service.go +++ b/internal/services/chapa/service.go @@ -2,15 +2,22 @@ package chapa import ( "context" + "database/sql" + "errors" "fmt" + + // "log/slog" "strconv" + "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" + "github.com/google/uuid" "github.com/shopspring/decimal" ) @@ -19,7 +26,11 @@ type Service struct { walletStore wallet.WalletStore userStore user.UserStore referralStore referralservice.ReferralStore - store *repository.Store + branchStore branch.BranchStore + chapaClient ChapaClient + config *config.Config + // logger *slog.Logger + store *repository.Store } func NewService( @@ -27,6 +38,8 @@ func NewService( walletStore wallet.WalletStore, userStore user.UserStore, referralStore referralservice.ReferralStore, + branchStore branch.BranchStore, + chapaClient ChapaClient, store *repository.Store, ) *Service { return &Service{ @@ -34,6 +47,8 @@ func NewService( walletStore: walletStore, userStore: userStore, referralStore: referralStore, + branchStore: branchStore, + chapaClient: chapaClient, store: store, } } @@ -53,6 +68,9 @@ func (s *Service) HandleChapaTransferWebhook(ctx context.Context, req domain.Cha txn, err := s.transactionStore.GetTransactionByID(ctx, referenceID) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("transaction with ID %d not found", referenceID) + } return err } if txn.Verified { @@ -93,6 +111,9 @@ func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.Chap // 2. Fetch transaction txn, err := s.transactionStore.GetTransactionByID(ctx, referenceID) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("transaction with ID %d not found", referenceID) + } return err } if txn.Verified { @@ -122,7 +143,7 @@ func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.Chap } // 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 { return err } @@ -135,3 +156,162 @@ func (s *Service) HandleChapaPaymentWebhook(ctx context.Context, req domain.Chap 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 +} diff --git a/internal/web_server/handlers/chapa.go b/internal/web_server/handlers/chapa.go index bfd1541..a2d9991 100644 --- a/internal/web_server/handlers/chapa.go +++ b/internal/web_server/handlers/chapa.go @@ -7,7 +7,6 @@ import ( "io" "net/http" - "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/gofiber/fiber/v2" "github.com/google/uuid" @@ -299,7 +298,7 @@ func (h *Handler) VerifyChapaPayment(c *fiber.Ctx) error { } switch txType.Type { - case config.ChapaConfig.ChapaTransferType: + case "Payout": var payload domain.ChapaWebHookTransfer if err := c.BodyParser(&payload); err != nil { return domain.UnProcessableEntityResponse(c) @@ -315,7 +314,7 @@ func (h *Handler) VerifyChapaPayment(c *fiber.Ctx) error { StatusCode: fiber.StatusOK, }) - case config.ChapaConfig.ChapaPaymentType: + case "API": var payload domain.ChapaWebHookPayment if err := c.BodyParser(&payload); err != nil { 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, + }, + }) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 0e1acfc..7d31a87 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -182,13 +182,16 @@ func (a *App) initAppRoutes() { a.fiber.Post("/transfer/refill/:id", a.authMiddleware, h.RefillWallet) //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.Get("/chapa/payments/verify/:tx_ref", h.VerifyTransaction) - group.Post("/chapa/payments/callback", h.ReceiveWebhook) - group.Get("/chapa/banks", h.GetBanks) - group.Post("/chapa/transfers", h.CreateTransfer) - group.Get("/chapa/transfers/verify/:transfer_ref", h.VerifyTransfer) + // group.Post("/chapa/payments/initialize", h.InitializePayment) + // group.Get("/chapa/payments/verify/:tx_ref", h.VerifyTransaction) + // group.Post("/chapa/payments/callback", h.ReceiveWebhook) + // group.Get("/chapa/banks", h.GetBanks) + // group.Post("/chapa/transfers", h.CreateTransfer) + // group.Get("/chapa/transfers/verify/:transfer_ref", h.VerifyTransfer) //Alea Play Virtual Game Routes group.Get("/alea-play/launch", a.authMiddleware, h.LaunchAleaGame)