diff --git a/Dockerfile b/Dockerfile index 6a4fd5a..d16c603 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM golang:1.24-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ -RUN go mod download +# RUN go mod download COPY . . RUN go build -ldflags="-s -w" -o ./bin/web ./cmd/main.go diff --git a/cmd/main.go b/cmd/main.go index 9fd5b73..16b64e5 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -49,6 +49,7 @@ import ( referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/report" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/result" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/santimpay" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/settings" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" @@ -126,6 +127,7 @@ func main() { walletSvc := wallet.NewService( wallet.WalletStore(store), wallet.TransferStore(store), + wallet.DirectDepositStore(store), notificatioStore, notificationSvc, userSvc, @@ -231,9 +233,14 @@ func main() { arifpaySvc := arifpay.NewArifpayService(cfg, transferStore, &http.Client{ Timeout: 30 * time.Second}) + santimpayClient := santimpay.NewSantimPayClient(cfg) + + santimpaySvc := santimpay.NewSantimPayService(santimpayClient, cfg, transferStore) + // Initialize and start HTTP server app := httpserver.NewApp( arifpaySvc, + santimpaySvc, issueReportingSvc, instSvc, currSvc, diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 6892944..968ba99 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -1,3 +1,21 @@ +CREATE TABLE direct_deposits ( + id BIGSERIAL PRIMARY KEY, + customer_id BIGINT NOT NULL REFERENCES users(id), + wallet_id BIGINT NOT NULL REFERENCES wallets(id), + amount NUMERIC(15, 2) NOT NULL, + bank_reference TEXT NOT NULL, + sender_account TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'rejected')), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + verified_by BIGINT REFERENCES users(id), + verification_notes TEXT, + verified_at TIMESTAMP +); + +CREATE INDEX idx_direct_deposits_status ON direct_deposits(status); +CREATE INDEX idx_direct_deposits_customer ON direct_deposits(customer_id); +CREATE INDEX idx_direct_deposits_reference ON direct_deposits(bank_reference); + CREATE TABLE IF NOT EXISTS users ( id BIGSERIAL PRIMARY KEY, first_name VARCHAR(255) NOT NULL, diff --git a/db/migrations/000007_setting_data.up.sql b/db/migrations/000007_setting_data.up.sql index 8b96ee4..78f0e29 100644 --- a/db/migrations/000007_setting_data.up.sql +++ b/db/migrations/000007_setting_data.up.sql @@ -1,22 +1,11 @@ -- Settings Initial Data INSERT INTO settings (key, value) -<<<<<<< HEAD -VALUES - ('max_number_of_outcomes', '30'), -======= VALUES ('sms_provider', '30'), ('max_number_of_outcomes', '30'), ->>>>>>> d43b12c589d32e4b6147cfb54a3b939c476bae6f ('bet_amount_limit', '100000'), ('daily_ticket_limit', '50'), ('total_winnings_limit', '1000000'), ('amount_for_bet_referral', '1000000'), -<<<<<<< HEAD - ('cashback_amount_cap', '1000') -ON CONFLICT (key) -DO UPDATE SET value = EXCLUDED.value; -======= ('cashback_amount_cap', '1000') ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; ->>>>>>> d43b12c589d32e4b6147cfb54a3b939c476bae6f diff --git a/db/query/direct_deposit.sql b/db/query/direct_deposit.sql new file mode 100644 index 0000000..96c52f5 --- /dev/null +++ b/db/query/direct_deposit.sql @@ -0,0 +1,30 @@ +-- name: CreateDirectDeposit :one +INSERT INTO direct_deposits ( + customer_id, + wallet_id, + amount, + bank_reference, + sender_account, + status +) VALUES ( + $1, $2, $3, $4, $5, $6 +) RETURNING *; + +-- name: GetDirectDeposit :one +SELECT * FROM direct_deposits WHERE id = $1; + +-- name: UpdateDirectDeposit :one +UPDATE direct_deposits +SET + status = $2, + verified_by = $3, + verification_notes = $4, + verified_at = $5 +WHERE id = $1 +RETURNING *; + +-- name: GetDirectDepositsByStatus :many +SELECT * FROM direct_deposits WHERE status = $1 ORDER BY created_at DESC; + +-- name: GetCustomerDirectDeposits :many +SELECT * FROM direct_deposits WHERE customer_id = $1 ORDER BY created_at DESC; \ No newline at end of file diff --git a/gen/db/direct_deposit.sql.go b/gen/db/direct_deposit.sql.go new file mode 100644 index 0000000..be02750 --- /dev/null +++ b/gen/db/direct_deposit.sql.go @@ -0,0 +1,199 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: direct_deposit.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CreateDirectDeposit = `-- name: CreateDirectDeposit :one +INSERT INTO direct_deposits ( + customer_id, + wallet_id, + amount, + bank_reference, + sender_account, + status +) VALUES ( + $1, $2, $3, $4, $5, $6 +) RETURNING id, customer_id, wallet_id, amount, bank_reference, sender_account, status, created_at, verified_by, verification_notes, verified_at +` + +type CreateDirectDepositParams struct { + CustomerID int64 `json:"customer_id"` + WalletID int64 `json:"wallet_id"` + Amount pgtype.Numeric `json:"amount"` + BankReference string `json:"bank_reference"` + SenderAccount string `json:"sender_account"` + Status string `json:"status"` +} + +func (q *Queries) CreateDirectDeposit(ctx context.Context, arg CreateDirectDepositParams) (DirectDeposit, error) { + row := q.db.QueryRow(ctx, CreateDirectDeposit, + arg.CustomerID, + arg.WalletID, + arg.Amount, + arg.BankReference, + arg.SenderAccount, + arg.Status, + ) + var i DirectDeposit + err := row.Scan( + &i.ID, + &i.CustomerID, + &i.WalletID, + &i.Amount, + &i.BankReference, + &i.SenderAccount, + &i.Status, + &i.CreatedAt, + &i.VerifiedBy, + &i.VerificationNotes, + &i.VerifiedAt, + ) + return i, err +} + +const GetCustomerDirectDeposits = `-- name: GetCustomerDirectDeposits :many +SELECT id, customer_id, wallet_id, amount, bank_reference, sender_account, status, created_at, verified_by, verification_notes, verified_at FROM direct_deposits WHERE customer_id = $1 ORDER BY created_at DESC +` + +func (q *Queries) GetCustomerDirectDeposits(ctx context.Context, customerID int64) ([]DirectDeposit, error) { + rows, err := q.db.Query(ctx, GetCustomerDirectDeposits, customerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DirectDeposit + for rows.Next() { + var i DirectDeposit + if err := rows.Scan( + &i.ID, + &i.CustomerID, + &i.WalletID, + &i.Amount, + &i.BankReference, + &i.SenderAccount, + &i.Status, + &i.CreatedAt, + &i.VerifiedBy, + &i.VerificationNotes, + &i.VerifiedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetDirectDeposit = `-- name: GetDirectDeposit :one +SELECT id, customer_id, wallet_id, amount, bank_reference, sender_account, status, created_at, verified_by, verification_notes, verified_at FROM direct_deposits WHERE id = $1 +` + +func (q *Queries) GetDirectDeposit(ctx context.Context, id int64) (DirectDeposit, error) { + row := q.db.QueryRow(ctx, GetDirectDeposit, id) + var i DirectDeposit + err := row.Scan( + &i.ID, + &i.CustomerID, + &i.WalletID, + &i.Amount, + &i.BankReference, + &i.SenderAccount, + &i.Status, + &i.CreatedAt, + &i.VerifiedBy, + &i.VerificationNotes, + &i.VerifiedAt, + ) + return i, err +} + +const GetDirectDepositsByStatus = `-- name: GetDirectDepositsByStatus :many +SELECT id, customer_id, wallet_id, amount, bank_reference, sender_account, status, created_at, verified_by, verification_notes, verified_at FROM direct_deposits WHERE status = $1 ORDER BY created_at DESC +` + +func (q *Queries) GetDirectDepositsByStatus(ctx context.Context, status string) ([]DirectDeposit, error) { + rows, err := q.db.Query(ctx, GetDirectDepositsByStatus, status) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DirectDeposit + for rows.Next() { + var i DirectDeposit + if err := rows.Scan( + &i.ID, + &i.CustomerID, + &i.WalletID, + &i.Amount, + &i.BankReference, + &i.SenderAccount, + &i.Status, + &i.CreatedAt, + &i.VerifiedBy, + &i.VerificationNotes, + &i.VerifiedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const UpdateDirectDeposit = `-- name: UpdateDirectDeposit :one +UPDATE direct_deposits +SET + status = $2, + verified_by = $3, + verification_notes = $4, + verified_at = $5 +WHERE id = $1 +RETURNING id, customer_id, wallet_id, amount, bank_reference, sender_account, status, created_at, verified_by, verification_notes, verified_at +` + +type UpdateDirectDepositParams struct { + ID int64 `json:"id"` + Status string `json:"status"` + VerifiedBy pgtype.Int8 `json:"verified_by"` + VerificationNotes pgtype.Text `json:"verification_notes"` + VerifiedAt pgtype.Timestamp `json:"verified_at"` +} + +func (q *Queries) UpdateDirectDeposit(ctx context.Context, arg UpdateDirectDepositParams) (DirectDeposit, error) { + row := q.db.QueryRow(ctx, UpdateDirectDeposit, + arg.ID, + arg.Status, + arg.VerifiedBy, + arg.VerificationNotes, + arg.VerifiedAt, + ) + var i DirectDeposit + err := row.Scan( + &i.ID, + &i.CustomerID, + &i.WalletID, + &i.Amount, + &i.BankReference, + &i.SenderAccount, + &i.Status, + &i.CreatedAt, + &i.VerifiedBy, + &i.VerificationNotes, + &i.VerifiedAt, + ) + return i, err +} diff --git a/gen/db/models.go b/gen/db/models.go index 575526a..a414f7f 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -234,6 +234,20 @@ type CustomerWalletDetail struct { PhoneNumber pgtype.Text `json:"phone_number"` } +type DirectDeposit struct { + ID int64 `json:"id"` + CustomerID int64 `json:"customer_id"` + WalletID int64 `json:"wallet_id"` + Amount pgtype.Numeric `json:"amount"` + BankReference string `json:"bank_reference"` + SenderAccount string `json:"sender_account"` + Status string `json:"status"` + CreatedAt pgtype.Timestamp `json:"created_at"` + VerifiedBy pgtype.Int8 `json:"verified_by"` + VerificationNotes pgtype.Text `json:"verification_notes"` + VerifiedAt pgtype.Timestamp `json:"verified_at"` +} + type Event struct { ID string `json:"id"` SportID pgtype.Int4 `json:"sport_id"` diff --git a/internal/config/config.go b/internal/config/config.go index e8ec70a..a9400a0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,6 +66,12 @@ type ARIFPAYConfig struct { SuccessUrl string `mapstructure:"VELI_BRAND_ID"` } +type SANTIMPAYConfig struct { + SecretKey string `mapstructure:"secret_key"` + MerchantID string `mapstructure:"merchant_id"` + BaseURL string `mapstructure:"base_url"` +} + type Config struct { FIXER_API_KEY string FIXER_BASE_URL string @@ -93,9 +99,10 @@ type Config struct { CHAPA_RETURN_URL string Bet365Token string PopOK domain.PopOKConfig - AleaPlay AleaPlayConfig `mapstructure:"alea_play"` - VeliGames VeliConfig `mapstructure:"veli_games"` - ARIFPAY ARIFPAYConfig `mapstructure:"arifpay_config"` + AleaPlay AleaPlayConfig `mapstructure:"alea_play"` + VeliGames VeliConfig `mapstructure:"veli_games"` + ARIFPAY ARIFPAYConfig `mapstructure:"arifpay_config"` + SANTIMPAY SANTIMPAYConfig `mapstructure:"santimpay_config"` ResendApiKey string ResendSenderEmail string TwilioAccountSid string @@ -204,6 +211,10 @@ func (c *Config) loadEnv() error { c.ARIFPAY.NotifyUrl = os.Getenv("ARIFPAY_NOTIFY_URL") c.ARIFPAY.SuccessUrl = os.Getenv("ARIFPAY_SUCCESS_URL") + c.SANTIMPAY.SecretKey = os.Getenv("SANTIMPAY_SECRET_KEY") + c.SANTIMPAY.MerchantID = os.Getenv("SANTIMPAY_MERCHANT_ID") + c.SANTIMPAY.BaseURL = os.Getenv("SANTIMPAY_Base_URL") + //Alea Play aleaEnabled := os.Getenv("ALEA_ENABLED") if aleaEnabled == "" { diff --git a/internal/domain/notification.go b/internal/domain/notification.go index d4a1a8e..30b1a14 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -14,6 +14,8 @@ type NotificationDeliveryStatus string type DeliveryChannel string const ( + NotificationTypeDepositResult NotificationType = "deposit_result" + NotificationTypeDepositVerification NotificationType = "deposit_verification" NotificationTypeCashOutSuccess NotificationType = "cash_out_success" NotificationTypeDepositSuccess NotificationType = "deposit_success" NotificationTypeWithdrawSuccess NotificationType = "withdraw_success" diff --git a/internal/domain/santimpay.go b/internal/domain/santimpay.go new file mode 100644 index 0000000..c616724 --- /dev/null +++ b/internal/domain/santimpay.go @@ -0,0 +1,25 @@ +package domain + +type GeneratePaymentURLInput struct { + ID string + Amount int + Reason string + PhoneNumber string + // SuccessRedirectURL string + // FailureRedirectURL string + // CancelRedirectURL string + // NotifyURL string +} + +type InitiatePaymentPayload struct { + ID string `json:"id"` + Amount int `json:"amount"` + Reason string `json:"paymentReason"` + MerchantID string `json:"merchantId"` + SignedToken string `json:"signedToken"` + SuccessRedirectURL string `json:"successRedirectUrl"` + FailureRedirectURL string `json:"failureRedirectUrl"` + NotifyURL string `json:"notifyUrl"` + CancelRedirectURL string `json:"cancelRedirectUrl"` + PhoneNumber string `json:"phoneNumber"` +} \ No newline at end of file diff --git a/internal/domain/wallet.go b/internal/domain/wallet.go index 6ae6a1f..aec3895 100644 --- a/internal/domain/wallet.go +++ b/internal/domain/wallet.go @@ -80,3 +80,58 @@ const ( BranchWalletType WalletType = "branch_wallet" CompanyWalletType WalletType = "company_wallet" ) + +// domain/wallet.go + +type DirectDepositStatus string + +const ( + DepositStatusPending DirectDepositStatus = "pending" + DepositStatusCompleted DirectDepositStatus = "completed" + DepositStatusRejected DirectDepositStatus = "rejected" +) + +type DirectDeposit struct { + ID int64 + CustomerID int64 + WalletID int64 + Wallet Wallet // Joined data + Amount Currency + BankReference string + SenderAccount string + Status DirectDepositStatus + CreatedAt time.Time + VerifiedBy *int64 // Nullable + VerificationNotes string + VerifiedAt *time.Time // Nullable +} + +type CreateDirectDeposit struct { + CustomerID int64 + WalletID int64 + Amount Currency + BankReference string + SenderAccount string + Status DirectDepositStatus +} + +type UpdateDirectDeposit struct { + ID int64 + Status DirectDepositStatus + VerifiedBy int64 + VerificationNotes string + VerifiedAt time.Time +} + +type DirectDepositRequest struct { + CustomerID int64 `json:"customer_id" binding:"required"` + Amount Currency `json:"amount" binding:"required,gt=0"` + BankReference string `json:"bank_reference" binding:"required"` + SenderAccount string `json:"sender_account" binding:"required"` +} + +type VerifyDirectDepositRequest struct { + DepositID int64 `json:"deposit_id" binding:"required"` + IsVerified bool `json:"is_verified" binding:"required"` + Notes string `json:"notes"` +} diff --git a/internal/repository/direct_deposit.go b/internal/repository/direct_deposit.go new file mode 100644 index 0000000..1fd5f1d --- /dev/null +++ b/internal/repository/direct_deposit.go @@ -0,0 +1,112 @@ +package repository + +import ( + "context" + "math/big" + "time" + + dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/jackc/pgx/v5/pgtype" +) + +func convertDBDirectDeposit(deposit dbgen.DirectDeposit) domain.DirectDeposit { + return domain.DirectDeposit{ + ID: deposit.ID, + CustomerID: deposit.CustomerID, + WalletID: deposit.WalletID, + Amount: domain.Currency(deposit.Amount.Int.Int64()), + BankReference: deposit.BankReference, + SenderAccount: deposit.SenderAccount, + Status: domain.DirectDepositStatus(deposit.Status), + CreatedAt: deposit.CreatedAt.Time, + VerifiedBy: convertPgInt64ToPtr(deposit.VerifiedBy), + VerificationNotes: deposit.VerificationNotes.String, + VerifiedAt: convertPgTimeToPtr(deposit.VerifiedAt), + } +} + +func convertCreateDirectDeposit(deposit domain.CreateDirectDeposit) dbgen.CreateDirectDepositParams { + return dbgen.CreateDirectDepositParams{ + CustomerID: deposit.CustomerID, + WalletID: deposit.WalletID, + Amount: pgtype.Numeric{Int: big.NewInt(int64(deposit.Amount)), Valid: true}, + BankReference: deposit.BankReference, + SenderAccount: deposit.SenderAccount, + Status: string(deposit.Status), + } +} + +func convertUpdateDirectDeposit(deposit domain.UpdateDirectDeposit) dbgen.UpdateDirectDepositParams { + return dbgen.UpdateDirectDepositParams{ + ID: deposit.ID, + Status: string(deposit.Status), + VerifiedBy: pgtype.Int8{Int64: deposit.VerifiedBy, Valid: true}, + VerificationNotes: pgtype.Text{String: deposit.VerificationNotes, Valid: deposit.VerificationNotes != ""}, + VerifiedAt: pgtype.Timestamp{Time: deposit.VerifiedAt, Valid: true}, + } +} + +func convertPgInt64ToPtr(i pgtype.Int8) *int64 { + if i.Valid { + return &i.Int64 + } + return nil +} + +func convertPgTimeToPtr(t pgtype.Timestamp) *time.Time { + if t.Valid { + return &t.Time + } + return nil +} + +func (s *Store) CreateDirectDeposit(ctx context.Context, deposit domain.CreateDirectDeposit) (domain.DirectDeposit, error) { + newDeposit, err := s.queries.CreateDirectDeposit(ctx, convertCreateDirectDeposit(deposit)) + if err != nil { + return domain.DirectDeposit{}, err + } + return convertDBDirectDeposit(newDeposit), nil +} + +func (s *Store) GetDirectDeposit(ctx context.Context, id int64) (domain.DirectDeposit, error) { + deposit, err := s.queries.GetDirectDeposit(ctx, id) + if err != nil { + return domain.DirectDeposit{}, err + } + return convertDBDirectDeposit(deposit), nil +} + +func (s *Store) UpdateDirectDeposit(ctx context.Context, deposit domain.UpdateDirectDeposit) (domain.DirectDeposit, error) { + updatedDeposit, err := s.queries.UpdateDirectDeposit(ctx, convertUpdateDirectDeposit(deposit)) + if err != nil { + return domain.DirectDeposit{}, err + } + return convertDBDirectDeposit(updatedDeposit), nil +} + +func (s *Store) GetDirectDepositsByStatus(ctx context.Context, status domain.DirectDepositStatus) ([]domain.DirectDeposit, error) { + deposits, err := s.queries.GetDirectDepositsByStatus(ctx, string(status)) + if err != nil { + return nil, err + } + + result := make([]domain.DirectDeposit, 0, len(deposits)) + for _, deposit := range deposits { + result = append(result, convertDBDirectDeposit(deposit)) + } + return result, nil +} + +func (s *Store) GetCustomerDirectDeposits(ctx context.Context, customerID int64) ([]domain.DirectDeposit, error) { + deposits, err := s.queries.GetCustomerDirectDeposits(ctx, customerID) + if err != nil { + return nil, err + } + + result := make([]domain.DirectDeposit, 0, len(deposits)) + for _, deposit := range deposits { + result = append(result, convertDBDirectDeposit(deposit)) + } + return result, nil +} diff --git a/internal/services/santimpay/client.go b/internal/services/santimpay/client.go new file mode 100644 index 0000000..8dbdfbc --- /dev/null +++ b/internal/services/santimpay/client.go @@ -0,0 +1,53 @@ +package santimpay + +import ( + "fmt" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/config" + "github.com/golang-jwt/jwt/v5" +) + +type SantimPayClient interface { + GenerateSignedToken(amount int, reason string) (string, error) + CheckTransactionStatus(id string) +} + +type santimClient struct { + cfg *config.Config +} + +func NewSantimPayClient(cfg *config.Config) SantimPayClient { + return &santimClient{ + cfg: cfg, + } +} + +func (c *santimClient) GenerateSignedToken(amount int, reason string) (string, error) { + now := time.Now().Unix() + + claims := jwt.MapClaims{ + "amount": amount, + "paymentReason": reason, + "merchantId": c.cfg.SANTIMPAY.MerchantID, + "generated": now, + } + + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + privateKey, err := jwt.ParseECPrivateKeyFromPEM([]byte(c.cfg.SANTIMPAY.SecretKey)) + if err != nil { + return "", fmt.Errorf("invalid private key: %w", err) + } + + signedToken, err := token.SignedString(privateKey) + if err != nil { + return "", fmt.Errorf("signing failed: %w", err) + } + + return signedToken, nil +} + +func (c *santimClient) CheckTransactionStatus(id string) { + // optional async checker — can log or poll transaction status + fmt.Println("Checking transaction status for:", id) +} diff --git a/internal/services/santimpay/service.go b/internal/services/santimpay/service.go new file mode 100644 index 0000000..eca451e --- /dev/null +++ b/internal/services/santimpay/service.go @@ -0,0 +1,92 @@ +package santimpay + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "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(input domain.GeneratePaymentURLInput) (map[string]string, error) +// } + +type SantimPayService struct { + client SantimPayClient + cfg *config.Config + transferStore wallet.TransferStore +} + +func NewSantimPayService(client SantimPayClient, cfg *config.Config, transferStore wallet.TransferStore) *SantimPayService { + return &SantimPayService{ + client: client, + cfg: cfg, + transferStore: transferStore, + } +} + +func (s *SantimPayService) GeneratePaymentURL(input domain.GeneratePaymentURLInput) (map[string]string, error) { + paymentID := uuid.NewString() + + token, err := s.client.GenerateSignedToken(input.Amount, input.Reason) + if err != nil { + return nil, fmt.Errorf("token generation failed: %w", err) + } + + payload := domain.InitiatePaymentPayload{ + ID: paymentID, + Amount: input.Amount, + Reason: input.Reason, + MerchantID: s.cfg.SANTIMPAY.MerchantID, + SignedToken: token, + SuccessRedirectURL: s.cfg.ARIFPAY.SuccessUrl, + FailureRedirectURL: s.cfg.ARIFPAY.ErrorUrl, + NotifyURL: s.cfg.ARIFPAY.NotifyUrl, + CancelRedirectURL: s.cfg.ARIFPAY.CancelUrl, + PhoneNumber: input.PhoneNumber, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload: %w", err) + } + + resp, err := http.Post(s.cfg.SANTIMPAY.BaseURL+"/initiate-payment", "application/json", bytes.NewBuffer(jsonData)) + 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) + } + + // Save transfer + transfer := domain.CreateTransfer{ + Amount: domain.Currency(input.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) + } + + // Optionally check transaction status in a goroutine + go s.client.CheckTransactionStatus(paymentID) + + return responseBody, nil +} diff --git a/internal/services/wallet/direct_deposit.go b/internal/services/wallet/direct_deposit.go new file mode 100644 index 0000000..fc25861 --- /dev/null +++ b/internal/services/wallet/direct_deposit.go @@ -0,0 +1,217 @@ +package wallet + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/event" +) + +// InitiateDirectDeposit creates a pending deposit request +func (s *Service) InitiateDirectDeposit( + ctx context.Context, + customerID int64, + amount domain.Currency, + bankRef string, // Mobile banking transaction reference + senderAccount string, // Customer's account number +) (domain.DirectDeposit, error) { + // Get customer's betting wallet + customerWallet, err := s.GetCustomerWallet(ctx, customerID) + if err != nil { + return domain.DirectDeposit{}, fmt.Errorf("failed to get customer wallet: %w", err) + } + + // Create pending deposit record + deposit, err := s.directDepositStore.CreateDirectDeposit(ctx, domain.CreateDirectDeposit{ + CustomerID: customerID, + WalletID: customerWallet.ID, + Amount: amount, + BankReference: bankRef, + SenderAccount: senderAccount, + Status: domain.DepositStatusPending, + }) + if err != nil { + return domain.DirectDeposit{}, fmt.Errorf("failed to create deposit record: %w", err) + } + + // Notify cashiers for manual verification + go s.notifyCashiersForVerification(ctx, deposit.ID, customerID, amount) + + return deposit, nil +} + +// VerifyDirectDeposit verifies and processes the deposit +func (s *Service) VerifyDirectDeposit( + ctx context.Context, + depositID int64, + cashierID int64, + isVerified bool, + verificationNotes string, +) (domain.DirectDeposit, error) { + // Get the deposit record + deposit, err := s.directDepositStore.GetDirectDeposit(ctx, depositID) + if err != nil { + return domain.DirectDeposit{}, fmt.Errorf("failed to get deposit: %w", err) + } + + // Validate deposit status + if deposit.Status != domain.DepositStatusPending { + return domain.DirectDeposit{}, errors.New("only pending deposits can be verified") + } + + // Update based on verification result + if isVerified { + // Credit the wallet + err = s.walletStore.UpdateBalance(ctx, deposit.WalletID, + deposit.Wallet.Balance+deposit.Amount) + if err != nil { + return domain.DirectDeposit{}, fmt.Errorf("failed to update wallet balance: %w", err) + } + + // Publish wallet update event + go s.publishWalletUpdate(ctx, deposit.WalletID, deposit.Wallet.UserID, + deposit.Wallet.Balance+deposit.Amount, "direct_deposit_verified") + + // Update deposit status + deposit.Status = domain.DepositStatusCompleted + } else { + deposit.Status = domain.DepositStatusRejected + } + + // Update deposit record + updatedDeposit, err := s.directDepositStore.UpdateDirectDeposit(ctx, domain.UpdateDirectDeposit{ + ID: depositID, + Status: deposit.Status, + VerifiedBy: cashierID, + VerificationNotes: verificationNotes, + VerifiedAt: time.Now(), + }) + if err != nil { + return domain.DirectDeposit{}, fmt.Errorf("failed to update deposit: %w", err) + } + + // Notify customer of verification result + go s.notifyCustomerVerificationResult(ctx, updatedDeposit) + + return updatedDeposit, nil +} + +// GetPendingDirectDeposits returns deposits needing verification +func (s *Service) GetPendingDirectDeposits(ctx context.Context) ([]domain.DirectDeposit, error) { + return s.directDepositStore.GetDirectDepositsByStatus(ctx, domain.DepositStatusPending) +} + +// Helper functions +func (s *Service) notifyCashiersForVerification(ctx context.Context, depositID, customerID int64, amount domain.Currency) { + cashiers, _, err := s.userSvc.GetAllCashiers(ctx, domain.UserFilter{Role: string(domain.RoleCashier)}) + if err != nil { + s.logger.Error("failed to get cashiers for notification", + "error", err, + "deposit_id", depositID) + return + } + + customer, err := s.userSvc.GetUserByID(ctx, customerID) + if err != nil { + s.logger.Error("failed to get customer details", + "error", err, + "customer_id", customerID) + return + } + + for _, cashier := range cashiers { + metadataMap := map[string]interface{}{ + "deposit_id": depositID, + "customer_id": customerID, + "amount": amount.Float32(), + } + metadataJSON, err := json.Marshal(metadataMap) + if err != nil { + s.logger.Error("failed to marshal notification metadata", + "error", err, + "deposit_id", depositID) + continue + } + notification := &domain.Notification{ + RecipientID: cashier.ID, + Type: domain.NotificationTypeDepositVerification, + Level: domain.NotificationLevelInfo, + DeliveryChannel: domain.DeliveryChannelInApp, + Payload: domain.NotificationPayload{ + Headline: "Direct Deposit Requires Verification", + Message: fmt.Sprintf("Customer %s deposited %.2f - please verify", customer.FirstName+" "+customer.LastName, amount.Float32()), + }, + Metadata: metadataJSON, + } + + if err := s.notificationStore.SendNotification(ctx, notification); err != nil { + s.logger.Error("failed to send verification notification", + "cashier_id", cashier.ID, + "error", err) + } + } +} + +func (s *Service) notifyCustomerVerificationResult(ctx context.Context, deposit domain.DirectDeposit) { + var ( + headline string + message string + level domain.NotificationLevel + ) + + if deposit.Status == domain.DepositStatusCompleted { + headline = "Deposit Verified" + message = fmt.Sprintf("Your deposit of %.2f has been credited to your wallet", deposit.Amount.Float32()) + level = domain.NotificationLevelSuccess + } else { + headline = "Deposit Rejected" + message = fmt.Sprintf("Your deposit of %.2f was not verified. Reason: %s", + deposit.Amount.Float32(), deposit.VerificationNotes) + level = domain.NotificationLevelError + } + + metadataMap := map[string]interface{}{ + "deposit_id": deposit.ID, + "amount": deposit.Amount.Float32(), + "status": string(deposit.Status), + } + metadataJSON, err := json.Marshal(metadataMap) + if err != nil { + s.logger.Error("failed to marshal notification metadata", + "error", err, + "deposit_id", deposit.ID) + return + } + + notification := &domain.Notification{ + RecipientID: deposit.CustomerID, + Type: domain.NotificationTypeDepositResult, + Level: level, + DeliveryChannel: domain.DeliveryChannelInApp, + Payload: domain.NotificationPayload{ + Headline: headline, + Message: message, + }, + Metadata: metadataJSON, + } + + if err := s.notificationStore.SendNotification(ctx, notification); err != nil { + s.logger.Error("failed to send deposit result notification", + "customer_id", deposit.CustomerID, + "error", err) + } +} + +func (s *Service) publishWalletUpdate(ctx context.Context, walletID, userID int64, newBalance domain.Currency, trigger string) { + s.kafkaProducer.Publish(ctx, fmt.Sprint(walletID), event.WalletEvent{ + EventType: event.WalletBalanceUpdated, + WalletID: walletID, + UserID: userID, + Balance: newBalance, + Trigger: trigger, + }) +} diff --git a/internal/services/wallet/port.go b/internal/services/wallet/port.go index 89ee268..29e21e1 100644 --- a/internal/services/wallet/port.go +++ b/internal/services/wallet/port.go @@ -45,3 +45,11 @@ type ApprovalStore interface { GetApprovalsByTransfer(ctx context.Context, transferID int64) ([]domain.TransactionApproval, error) GetPendingApprovals(ctx context.Context) ([]domain.TransferDetail, error) } + +type DirectDepositStore interface { + CreateDirectDeposit(ctx context.Context, deposit domain.CreateDirectDeposit) (domain.DirectDeposit, error) + GetDirectDeposit(ctx context.Context, id int64) (domain.DirectDeposit, error) + UpdateDirectDeposit(ctx context.Context, deposit domain.UpdateDirectDeposit) (domain.DirectDeposit, error) + GetDirectDepositsByStatus(ctx context.Context, status domain.DirectDepositStatus) ([]domain.DirectDeposit, error) + GetCustomerDirectDeposits(ctx context.Context, customerID int64) ([]domain.DirectDeposit, error) +} diff --git a/internal/services/wallet/service.go b/internal/services/wallet/service.go index 4217dd8..8b0d216 100644 --- a/internal/services/wallet/service.go +++ b/internal/services/wallet/service.go @@ -11,19 +11,21 @@ import ( type Service struct { // approvalStore ApprovalStore - walletStore WalletStore - transferStore TransferStore - notificationStore notificationservice.NotificationStore - notificationSvc *notificationservice.Service - userSvc *user.Service - mongoLogger *zap.Logger - logger *slog.Logger - kafkaProducer *kafka.Producer + walletStore WalletStore + transferStore TransferStore + directDepositStore DirectDepositStore + notificationStore notificationservice.NotificationStore + notificationSvc *notificationservice.Service + userSvc *user.Service + mongoLogger *zap.Logger + logger *slog.Logger + kafkaProducer *kafka.Producer } func NewService( walletStore WalletStore, transferStore TransferStore, + directDepositStore DirectDepositStore, notificationStore notificationservice.NotificationStore, notificationSvc *notificationservice.Service, userSvc *user.Service, @@ -32,14 +34,15 @@ func NewService( kafkaProducer *kafka.Producer, ) *Service { return &Service{ - walletStore: walletStore, - transferStore: transferStore, + walletStore: walletStore, + transferStore: transferStore, + directDepositStore: directDepositStore, // approvalStore: approvalStore, notificationStore: notificationStore, notificationSvc: notificationSvc, userSvc: userSvc, mongoLogger: mongoLogger, logger: logger, - kafkaProducer: kafkaProducer, + kafkaProducer: kafkaProducer, } } diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 92a884c..c06216d 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -22,6 +22,7 @@ import ( referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/report" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/result" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/santimpay" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/settings" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" @@ -42,6 +43,7 @@ import ( type App struct { arifpaySvc *arifpay.ArifpayService + santimpaySvc *santimpay.SantimPayService issueReportingSvc *issuereporting.Service instSvc *institutions.Service currSvc *currency.Service @@ -79,6 +81,7 @@ type App struct { func NewApp( arifpaySvc *arifpay.ArifpayService, + santimpaySvc *santimpay.SantimPayService, issueReportingSvc *issuereporting.Service, instSvc *institutions.Service, currSvc *currency.Service, @@ -126,6 +129,7 @@ func NewApp( s := &App{ arifpaySvc: arifpaySvc, + santimpaySvc: santimpaySvc, issueReportingSvc: issueReportingSvc, instSvc: instSvc, currSvc: currSvc, diff --git a/internal/web_server/handlers/direct_deposit.go b/internal/web_server/handlers/direct_deposit.go new file mode 100644 index 0000000..64f1fde --- /dev/null +++ b/internal/web_server/handlers/direct_deposit.go @@ -0,0 +1,124 @@ +package handlers + +import ( + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/gofiber/fiber/v2" +) + +// InitiateDirectDeposit godoc +// @Summary Initiate a direct deposit +// @Description Customer initiates a direct deposit from mobile banking +// @Tags Direct Deposits +// @Accept json +// @Produce json +// @Param request body domain.DirectDepositRequest true "Deposit details" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/direct_deposit [post] +func (h *Handler) InitiateDirectDeposit(c *fiber.Ctx) error { + var req domain.DirectDepositRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Error: err.Error(), + Message: "Invalid request payload", + }) + } + + deposit, err := h.walletSvc.InitiateDirectDeposit( + c.Context(), + req.CustomerID, + req.Amount, + req.BankReference, + req.SenderAccount, + ) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Error: err.Error(), + Message: "Failed to initiate direct deposit", + }) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Direct deposit initiated successfully", + Data: deposit, + Success: true, + StatusCode: fiber.StatusCreated, + }) +} + +// VerifyDirectDeposit godoc +// @Summary Verify a direct deposit +// @Description Cashier verifies a direct deposit transaction +// @Tags Direct Deposits +// @Accept json +// @Produce json +// @Param request body domain.VerifyDepositRequest true "Verification details" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 401 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/direct_deposit/verify [post] +func (h *Handler) VerifyDirectDeposit(c *fiber.Ctx) error { + var req domain.VerifyDirectDepositRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Error: err.Error(), + Message: "Invalid verification request", + }) + } + + cashierID := c.Locals("user_id") + if cashierID == nil { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Error: "missing user_id in context", + Message: "Unauthorized access", + }) + } + + deposit, err := h.walletSvc.VerifyDirectDeposit( + c.Context(), + req.DepositID, + cashierID.(int64), + req.IsVerified, + req.Notes, + ) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Error: err.Error(), + Message: "Failed to verify deposit", + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Deposit verification processed successfully", + Data: deposit, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// GetPendingDeposits godoc +// @Summary Get pending direct deposits +// @Description Get list of direct deposits needing verification +// @Tags Direct Deposits +// @Produce json +// @Success 200 {object} domain.Response +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/direct_deposit/pending [get] +func (h *Handler) GetPendingDirectDeposits(c *fiber.Ctx) error { + deposits, err := h.walletSvc.GetPendingDirectDeposits(c.Context()) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Error: err.Error(), + Message: "Failed to retrieve pending deposits", + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Pending deposits retrieved successfully", + Data: deposits, + Success: true, + StatusCode: fiber.StatusOK, + }) +} \ No newline at end of file diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index deeea33..640c9ef 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -22,6 +22,7 @@ import ( referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/report" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/result" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/santimpay" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/settings" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" @@ -37,6 +38,7 @@ import ( type Handler struct { arifpaySvc *arifpay.ArifpayService + santimpaySvc *santimpay.SantimPayService issueReportingSvc *issuereporting.Service instSvc *institutions.Service currSvc *currency.Service @@ -71,6 +73,7 @@ type Handler struct { func New( arifpaySvc *arifpay.ArifpayService, + santimpaySvc *santimpay.SantimPayService, issueReportingSvc *issuereporting.Service, instSvc *institutions.Service, currSvc *currency.Service, @@ -104,6 +107,7 @@ func New( ) *Handler { return &Handler{ arifpaySvc: arifpaySvc, + santimpaySvc: santimpaySvc, issueReportingSvc: issueReportingSvc, instSvc: instSvc, currSvc: currSvc, diff --git a/internal/web_server/handlers/santimpay.go b/internal/web_server/handlers/santimpay.go new file mode 100644 index 0000000..6920200 --- /dev/null +++ b/internal/web_server/handlers/santimpay.go @@ -0,0 +1,43 @@ +package handlers + +import ( + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/gofiber/fiber/v2" +) + +// CreateSantimPayPaymentHandler initializes a payment session with SantimPay. +// +// @Summary Create SantimPay Payment Session +// @Description Generates a payment URL using SantimPay and returns it to the client. +// @Tags SantimPay +// @Accept json +// @Produce json +// @Param request body domain.GeneratePaymentURLInput true "SantimPay payment request payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/santimpay/payment [post] +func (h *Handler) CreateSantimPayPaymentHandler(c *fiber.Ctx) error { + var req domain.GeneratePaymentURLInput + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Error: err.Error(), + Message: "Failed to process your request", + }) + } + + paymentURL, err := h.santimpaySvc.GeneratePaymentURL(req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Error: err.Error(), + Message: "Failed to initiate SantimPay payment session", + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "SantimPay payment URL generated successfully", + Data: paymentURL, + Success: true, + StatusCode: fiber.StatusOK, + }) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 3a4083b..93d7bab 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -21,6 +21,7 @@ import ( func (a *App) initAppRoutes() { h := handlers.New( a.arifpaySvc, + a.santimpaySvc, a.issueReportingSvc, a.instSvc, a.currSvc, @@ -60,10 +61,18 @@ func (a *App) initAppRoutes() { }) }) + groupV1 := a.fiber.Group("/api/v1") + + //Direct_deposit + groupV1.Post("/direct_deposit", a.authMiddleware, h.InitiateDirectDeposit) + groupV1.Post("/direct_deposit/verify", a.authMiddleware, h.VerifyDirectDeposit) + groupV1.Get("/direct_deposit/pending", a.authMiddleware, h.GetPendingDirectDeposits) + groupV1.Post("/auth/admin-login", h.LoginAdmin) + groupV1.Post("/auth/refresh", h.RefreshToken) + // Swagger a.fiber.Get("/swagger/*", fiberSwagger.FiberWrapHandler()) - groupV1 := a.fiber.Group("/api/v1") groupV1.Get("/", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{ "message": "FortuneBet API V1 pre-alpha", @@ -108,6 +117,12 @@ func (a *App) initAppRoutes() { groupV1.Post("/arifpay/transaction-id/verify-transaction", a.authMiddleware, h.ArifpayVerifyByTransactionIDHandler) groupV1.Get("/arifpay/session-id/verify-transaction/:session_id", a.authMiddleware, h.ArifpayVerifyBySessionIDHandler) + //Santimpay + groupV1.Post("/santimpay/init-payment", h.CreateSantimPayPaymentHandler) + // groupV1.Post("/arifpay/b2c/transfer", a.authMiddleware, h.B2CTransferHandler) + // groupV1.Post("/arifpay/transaction-id/verify-transaction", a.authMiddleware, h.ArifpayVerifyByTransactionIDHandler) + // groupV1.Get("/arifpay/session-id/verify-transaction/:session_id", a.authMiddleware, h.ArifpayVerifyBySessionIDHandler) + // User Routes groupV1.Post("/user/resetPassword", h.ResetPassword) groupV1.Post("/user/sendResetCode", h.SendResetCode)