diff --git a/Dockerfile b/Dockerfile index 6a4fd5a..2f5adc5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,4 +14,4 @@ COPY .env . COPY --from=builder /app/bin/web /app/bin/web RUN apk add --no-cache ca-certificates EXPOSE ${PORT} -CMD ["/app/bin/web"] \ No newline at end of file +CMD ["/app/bin/web"] diff --git a/cmd/main.go b/cmd/main.go index 07fe744..56ad15a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -49,7 +49,9 @@ 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/telebirr" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" @@ -126,6 +128,7 @@ func main() { walletSvc := wallet.NewService( wallet.WalletStore(store), wallet.TransferStore(store), + wallet.DirectDepositStore(store), notificatioStore, notificationSvc, userSvc, @@ -227,13 +230,21 @@ func main() { issueReportingSvc := issuereporting.New(issueReportingRepo) transferStore := wallet.TransferStore(store) + // walletStore := wallet.WalletStore(store) arifpaySvc := arifpay.NewArifpayService(cfg, transferStore, &http.Client{ Timeout: 30 * time.Second}) + santimpayClient := santimpay.NewSantimPayClient(cfg) + + santimpaySvc := santimpay.NewSantimPayService(santimpayClient, cfg, transferStore) + telebirrSvc := telebirr.NewTelebirrService(cfg, transferStore, walletSvc) + // Initialize and start HTTP server app := httpserver.NewApp( + telebirrSvc, arifpaySvc, + santimpaySvc, issueReportingSvc, instSvc, currSvc, diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 28ce27c..e388a5e 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 e98967b..458da81 100644 --- a/db/migrations/000007_setting_data.up.sql +++ b/db/migrations/000007_setting_data.up.sql @@ -1,11 +1,20 @@ -- Settings Initial Data INSERT INTO settings (key, value) VALUES ('sms_provider', '30'), +<<<<<<< HEAD +('max_number_of_outcomes', '30'), + ('bet_amount_limit', '100000'), +======= ('max_number_of_outcomes', '30'), ('bet_amount_limit', '10000000'), +>>>>>>> 7d8d824a94381bd82c40398654c3bd78218c5950 ('daily_ticket_limit', '50'), ('total_winnings_limit', '1000000'), ('amount_for_bet_referral', '1000000'), ('cashback_amount_cap', '1000') ON CONFLICT (key) DO UPDATE -SET value = EXCLUDED.value; \ No newline at end of file +<<<<<<< HEAD +SET value = EXCLUDED.value; +======= +SET value = EXCLUDED.value; +>>>>>>> 7d8d824a94381bd82c40398654c3bd78218c5950 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 7435b97..fb4c8c6 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..3730309 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,6 +66,20 @@ 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 TELEBIRRConfig struct { + TelebirrFabricAppID string `mapstructure:"fabric_app_id"` + TelebirrAppSecret string `mapstructure:"appSecret"` + TelebirrBaseURL string `mapstructure:"base_url"` + TelebirrMerchantCode string `mapstructure:"merchant_code"` + TelebirrCallbackURL string `mapstructure:"callback_url"` +} + type Config struct { FIXER_API_KEY string FIXER_BASE_URL string @@ -93,9 +107,11 @@ 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"` + TELEBIRR TELEBIRRConfig `mapstructure:"telebirr_config"` ResendApiKey string ResendSenderEmail string TwilioAccountSid string @@ -187,6 +203,13 @@ func (c *Config) loadEnv() error { return ErrInvalidLevel } + //Telebirr + c.TELEBIRR.TelebirrBaseURL = os.Getenv("TELEBIRR_BASE_URL") + c.TELEBIRR.TelebirrAppSecret = os.Getenv("TELEBIRR_APP_SECRET") + c.TELEBIRR.TelebirrAppSecret = os.Getenv("TELEBIRR_FABRIC_APP_ID") + c.TELEBIRR.TelebirrMerchantCode = os.Getenv("TELEBIRR_MERCHANT_CODE") + c.TELEBIRR.TelebirrCallbackURL = os.Getenv("TELEBIRR_CALLBACK_URL") + //Chapa c.CHAPA_SECRET_KEY = os.Getenv("CHAPA_SECRET_KEY") c.CHAPA_PUBLIC_KEY = os.Getenv("CHAPA_PUBLIC_KEY") @@ -204,6 +227,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 4f91c0c..d10f3d7 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/telebirr.go b/internal/domain/telebirr.go new file mode 100644 index 0000000..02fc02d --- /dev/null +++ b/internal/domain/telebirr.go @@ -0,0 +1,60 @@ +package domain + +type TelebirrFabricTokenResponse struct { + Token string `json:"token"` + EffectiveDate string `json:"effectiveDate"` + ExpirationDate string `json:"expirationDate"` +} + +type TelebirrBizContent struct { + NotifyURL string `json:"notify_url"` + AppID string `json:"appid"` + MerchCode string `json:"merch_code"` + MerchOrderID string `json:"merch_order_id"` + TradeType string `json:"trade_type"` + Title string `json:"title"` + TotalAmount string `json:"total_amount"` + TransCurrency string `json:"trans_currency"` + TimeoutExpress string `json:"timeout_express"` + BusinessType string `json:"business_type"` + PayeeIdentifier string `json:"payee_identifier"` + PayeeIdentifierType string `json:"payee_identifier_type"` + PayeeType string `json:"payee_type"` + RedirectURL string `json:"redirect_url"` + CallbackInfo string `json:"callback_info"` +} + +type TelebirrPreOrderRequestPayload struct { + Timestamp string `json:"timestamp"` + NonceStr string `json:"nonce_str"` + Method string `json:"method"` + Version string `json:"version"` + BizContent TelebirrBizContent `json:"biz_content"` + SignType string `json:"sign_type"` + Sign string `json:"sign"` +} + +type TelebirrCheckoutParams struct { + AppID string `json:"appid"` + MerchCode string `json:"merch_code"` + NonceStr string `json:"nonce_str"` + PrepayID string `json:"prepay_id"` + Timestamp string `json:"timestamp"` +} + +type TelebirrPaymentCallbackPayload struct { + NotifyURL string `json:"notify_url"` // Optional callback URL + AppID string `json:"appid"` // App ID provided by Telebirr + NotifyTime string `json:"notify_time"` // Notification timestamp (UTC, in seconds) + MerchCode string `json:"merch_code"` // Merchant short code + MerchOrderID string `json:"merch_order_id"` // Order ID from merchant system + PaymentOrderID string `json:"payment_order_id"` // Order ID from Telebirr system + TotalAmount string `json:"total_amount"` // Payment amount + TransID string `json:"trans_id"` // Transaction ID + TransCurrency string `json:"trans_currency"` // Currency type (e.g., ETB) + TradeStatus string `json:"trade_status"` // Payment status (e.g., Completed, Failure) + TransEndTime string `json:"trans_end_time"` // Transaction end time (UTC seconds) + CallbackInfo string `json:"callback_info"` // Optional merchant-defined callback data + Sign string `json:"sign"` // Signature of the payload + SignType string `json:"sign_type"` // Signature type, e.g., SHA256WithRSA +} 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/telebirr/service.go b/internal/services/telebirr/service.go new file mode 100644 index 0000000..ecca8d5 --- /dev/null +++ b/internal/services/telebirr/service.go @@ -0,0 +1,406 @@ +package telebirr + +import ( + "bytes" + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/config" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" +) + +// TokenResponse is the expected response from Telebirr + +type TelebirrService struct { + // client TelebirrClient + cfg *config.Config + transferStore wallet.TransferStore + walletSvc *wallet.Service +} + +func NewTelebirrService(cfg *config.Config, transferStore wallet.TransferStore, walletSvc *wallet.Service) *TelebirrService { + return &TelebirrService{ + cfg: cfg, + transferStore: transferStore, + walletSvc: walletSvc, + } +} + +// GetFabricToken fetches the fabric token from Telebirr +func GetTelebirrFabricToken(s *TelebirrService) (*domain.TelebirrFabricTokenResponse, error) { + // Prepare the request body + bodyMap := map[string]string{ + "appSecret": s.cfg.TELEBIRR.TelebirrAppSecret, + } + bodyBytes, err := json.Marshal(bodyMap) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %v", err) + } + + // Prepare the HTTP request + req, err := http.NewRequest("POST", s.cfg.TELEBIRR.TelebirrBaseURL+"/payment/v1/token", bytes.NewBuffer(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-APP-Key", s.cfg.TELEBIRR.TelebirrFabricAppID) + + // Perform the request + client := &http.Client{ + Timeout: 10 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to perform request: %v", err) + } + defer resp.Body.Close() + + // Read and parse the response + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("non-200 response: %d, body: %s", resp.StatusCode, string(respBody)) + } + + var tokenResp domain.TelebirrFabricTokenResponse + if err := json.Unmarshal(respBody, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %v", err) + } + + return &tokenResp, nil +} + +func (s *TelebirrService) CreateTelebirrOrder(title string, amount float32, userID int64) (string, error) { + // Step 1: Get Fabric Token + tokenResp, err := GetTelebirrFabricToken(s) + if err != nil { + return "", fmt.Errorf("failed to get token: %v", err) + } + fabricToken := tokenResp.Token + + // Step 2: Create request object + orderID := fmt.Sprintf("%d", time.Now().UnixNano()) + bizContent := domain.TelebirrBizContent{ + NotifyURL: s.cfg.TELEBIRR.TelebirrCallbackURL, // Replace with actual + AppID: s.cfg.TELEBIRR.TelebirrFabricAppID, + MerchCode: s.cfg.TELEBIRR.TelebirrMerchantCode, + MerchOrderID: orderID, + TradeType: "Checkout", + Title: title, + TotalAmount: fmt.Sprintf("%.2f", amount), + TransCurrency: "ETB", + TimeoutExpress: "120m", + BusinessType: "WalletRefill", + PayeeIdentifier: s.cfg.TELEBIRR.TelebirrMerchantCode, + PayeeIdentifierType: "04", + PayeeType: "5000", + RedirectURL: s.cfg.ARIFPAY.SuccessUrl, // Replace with actual + CallbackInfo: "From web", + } + + requestPayload := domain.TelebirrPreOrderRequestPayload{ + Timestamp: fmt.Sprintf("%d", time.Now().Unix()), + NonceStr: generateNonce(), + Method: "payment.preorder", + Version: "1.0", + BizContent: bizContent, + SignType: "SHA256WithRSA", + } + + // Sign the request + signStr := canonicalSignString(preOrderPayloadToMap(requestPayload)) + signature, err := signSHA256WithRSA(signStr, s.cfg.TELEBIRR.TelebirrAppSecret) + if err != nil { + return "", fmt.Errorf("failed to sign request: %v", err) + } + requestPayload.Sign = signature + + // Marshal to JSON + bodyBytes, _ := json.Marshal(requestPayload) + + // Step 3: Make the request + req, _ := http.NewRequest("POST", s.cfg.TELEBIRR.TelebirrBaseURL+"/payment/v1/merchant/preOrder", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-APP-Key", s.cfg.TELEBIRR.TelebirrFabricAppID) + req.Header.Set("Authorization", fabricToken) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("telebirr preOrder request failed: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("telebirr preOrder failed: %s", string(body)) + } + + var response map[string]interface{} + if err := json.Unmarshal(body, &response); err != nil { + return "", fmt.Errorf("telebirr response parse error: %v", err) + } + + biz := response["biz_content"].(map[string]interface{}) + prepayID := biz["prepay_id"].(string) + + // Step 4: Build checkout URL + checkoutURL, err := s.BuildTelebirrCheckoutURL(prepayID) + if err != nil { + return "", err + } + + SenderWallets, err := s.walletSvc.GetWalletsByUser(req.Context(), userID) + if err != nil { + return "", fmt.Errorf("failed to get user wallets: %v", err) + } + + s.transferStore.CreateTransfer(req.Context(), domain.CreateTransfer{ + Amount: domain.Currency(amount), + Verified: false, + Type: domain.DEPOSIT, + ReferenceNumber: orderID, + Status: string(domain.PaymentStatusPending), + SenderWalletID: domain.ValidInt64{ + Value: SenderWallets[0].ID, + Valid: true, + }, + Message: fmt.Sprintf("Telebirr order created with ID: %s and amount: %f", orderID, amount), + }) + + return checkoutURL, nil +} + +func (s *TelebirrService) BuildTelebirrCheckoutURL(prepayID string) (string, error) { + + // Convert params struct to map[string]string for signing + params := map[string]string{ + "app_id": s.cfg.TELEBIRR.TelebirrFabricAppID, + "merch_code": s.cfg.TELEBIRR.TelebirrMerchantCode, + "nonce_str": generateNonce(), + "prepay_id": prepayID, + "timestamp": fmt.Sprintf("%d", time.Now().Unix()), + } + signStr := canonicalSignString(params) + signature, err := signSHA256WithRSA(signStr, s.cfg.TELEBIRR.TelebirrAppSecret) + if err != nil { + return "", fmt.Errorf("failed to sign checkout URL: %v", err) + } + + query := url.Values{} + for k, v := range params { + query.Set(k, v) + } + query.Set("sign", signature) + query.Set("sign_type", "SHA256WithRSA") + query.Set("version", "1.0") + query.Set("trade_type", "Checkout") + + // Step 4: Build final URL + return s.cfg.TELEBIRR.TelebirrBaseURL + query.Encode(), nil +} + +func (s *TelebirrService) HandleTelebirrPaymentCallback(ctx context.Context, payload *domain.TelebirrPaymentCallbackPayload) error { + + transfer, err := s.transferStore.GetTransferByReference(ctx, payload.PaymentOrderID) + + if err != nil { + return fmt.Errorf("failed to fetch transfer by reference: %w", err) + } + + if transfer.Status != string(domain.PaymentStatusPending) { + return fmt.Errorf("payment not pending, status: %s", transfer.Status) + } else if transfer.Verified == true { + return fmt.Errorf("payment already verified") + } + + if payload.TradeStatus != "Completed" { + return fmt.Errorf("payment not completed, status: %s", payload.TradeStatus) + } + + // 1. Validate the signature + // if err := s.VerifyCallbackSignature(payload); err != nil { + // return fmt.Errorf("invalid callback signature: %w", err) + // } + + if err := s.transferStore.UpdateTransferStatus(ctx, transfer.ID, string(domain.PaymentStatusCompleted)); err != nil { + return fmt.Errorf("failed to update transfer status: %w", err) + } + + // 4. Parse amount + amount, err := strconv.ParseFloat(payload.TotalAmount, 64) + if err != nil { + return fmt.Errorf("invalid amount format: %s", payload.TotalAmount) + } + _, err = s.walletSvc.AddToWallet(ctx, transfer.SenderWalletID.Value, domain.Currency(amount), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}, fmt.Sprintf("Added %v to wallet for Telebirr payment", amount)) + if err != nil { + return fmt.Errorf("failed to add amount to wallet: %w", err) + } + + if err := s.transferStore.UpdateTransferVerification(ctx, transfer.ID, true); err != nil { + return fmt.Errorf("failed to update transfer verification: %w", err) + } + + return nil +} + +// Verifies the RSA-SHA256 signature of the payload +// func (s *TelebirrService) VerifyCallbackSignature(payload *domain.TelebirrPaymentCallbackPayload) error { +// // 1. Extract the signature from the payload +// signatureBase64 := payload.Sign +// signType := payload.SignType + +// if signType != "SHA256WithRSA" { +// return fmt.Errorf("unsupported sign_type: %s", signType) +// } + +// // 2. Convert the payload to map (excluding 'sign' and 'sign_type') +// payloadMap := map[string]string{ +// "notify_url": payload.NotifyURL, +// "appid": payload.AppID, +// "notify_time": payload.NotifyTime, +// "merch_code": payload.MerchCode, +// "merch_order_id": payload.MerchOrderID, +// "payment_order_id": payload.PaymentOrderID, +// "total_amount": payload.TotalAmount, +// "trans_id": payload.TransID, +// "trans_currency": payload.TransCurrency, +// "trade_status": payload.TradeStatus, +// "trans_end_time": payload.TransEndTime, +// } + +// // 3. Sort the keys and build the canonical string +// var keys []string +// for k := range payloadMap { +// keys = append(keys, k) +// } +// sort.Strings(keys) + +// var canonicalParts []string +// for _, k := range keys { +// canonicalParts = append(canonicalParts, fmt.Sprintf("%s=%s", k, payloadMap[k])) +// } +// canonicalString := strings.Join(canonicalParts, "&") + +// // 4. Hash the canonical string +// hashed := sha256.Sum256([]byte(canonicalString)) + +// // 5. Decode the base64 signature +// signature, err := base64.StdEncoding.DecodeString(signatureBase64) +// if err != nil { +// return fmt.Errorf("failed to decode signature: %w", err) +// } + +// // 6. Load the RSA public key (PEM format) +// pubKeyPEM := []byte(s.cfg.TELEBIRR.PublicKey) // Must be full PEM string + +// block, _ := pem.Decode(pubKeyPEM) +// if block == nil || block.Type != "PUBLIC KEY" { +// return errors.New("invalid public key PEM block") +// } + +// pubKeyInterface, err := x509.ParsePKIXPublicKey(block.Bytes) +// if err != nil { +// return fmt.Errorf("failed to parse RSA public key: %w", err) +// } + +// rsaPubKey, ok := pubKeyInterface.(*rsa.PublicKey) +// if !ok { +// return errors.New("not a valid RSA public key") +// } + +// // 7. Verify the signature +// err = rsa.VerifyPKCS1v15(rsaPubKey, crypto.SHA256, hashed[:], signature) +// if err != nil { +// return fmt.Errorf("RSA signature verification failed: %w", err) +// } + +// return nil +// } + +func generateNonce() string { + return fmt.Sprintf("telebirr%x", time.Now().UnixNano()) +} + +func canonicalSignString(data map[string]string) string { + keys := make([]string, 0, len(data)) + for k := range data { + if k != "sign" && k != "sign_type" { + keys = append(keys, k) + } + } + sort.Strings(keys) + + var b strings.Builder + for i, k := range keys { + value := data[k] + var valStr string + if k == "biz_content" { + jsonVal, _ := json.Marshal(value) + valStr = string(jsonVal) + } else { + valStr = fmt.Sprintf("%v", value) + } + b.WriteString(fmt.Sprintf("%s=%s", k, valStr)) + if i < len(keys)-1 { + b.WriteString("&") + } + } + return b.String() +} + +func signSHA256WithRSA(signStr, privateKeyPEM string) (string, error) { + block, _ := pem.Decode([]byte(privateKeyPEM)) + if block == nil { + return "", fmt.Errorf("invalid PEM private key") + } + + priv, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("unable to parse private key: %v", err) + } + + hashed := sha256.Sum256([]byte(signStr)) + + sig, err := rsa.SignPKCS1v15(rand.Reader, priv.(*rsa.PrivateKey), crypto.SHA256, hashed[:]) + if err != nil { + return "", fmt.Errorf("signing failed: %v", err) + } + + return base64.StdEncoding.EncodeToString(sig), nil +} + +// Helper function to convert TelebirrPreOrderRequestPayload to map[string]string for signing +func preOrderPayloadToMap(payload domain.TelebirrPreOrderRequestPayload) map[string]string { + m := map[string]string{ + "timestamp": payload.Timestamp, + "nonce_str": payload.NonceStr, + "method": payload.Method, + "version": payload.Version, + "sign_type": payload.SignType, + } + // BizContent needs to be marshaled as JSON string + bizContentBytes, _ := json.Marshal(payload.BizContent) + m["biz_content"] = string(bizContentBytes) + return m +} 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..7313199 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -22,7 +22,9 @@ 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/telebirr" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" @@ -41,7 +43,9 @@ import ( ) type App struct { + telebirrSvc *telebirr.TelebirrService arifpaySvc *arifpay.ArifpayService + santimpaySvc *santimpay.SantimPayService issueReportingSvc *issuereporting.Service instSvc *institutions.Service currSvc *currency.Service @@ -78,7 +82,9 @@ type App struct { } func NewApp( + telebirrSvc *telebirr.TelebirrService, arifpaySvc *arifpay.ArifpayService, + santimpaySvc *santimpay.SantimPayService, issueReportingSvc *issuereporting.Service, instSvc *institutions.Service, currSvc *currency.Service, @@ -125,7 +131,9 @@ func NewApp( })) s := &App{ + telebirrSvc: telebirrSvc, 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..a59dbc9 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -22,7 +22,9 @@ 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/telebirr" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" @@ -36,7 +38,9 @@ import ( ) type Handler struct { + telebirrSvc *telebirr.TelebirrService arifpaySvc *arifpay.ArifpayService + santimpaySvc *santimpay.SantimPayService issueReportingSvc *issuereporting.Service instSvc *institutions.Service currSvc *currency.Service @@ -70,7 +74,9 @@ type Handler struct { } func New( + telebirrSvc *telebirr.TelebirrService, arifpaySvc *arifpay.ArifpayService, + santimpaySvc *santimpay.SantimPayService, issueReportingSvc *issuereporting.Service, instSvc *institutions.Service, currSvc *currency.Service, @@ -103,7 +109,9 @@ func New( mongoLoggerSvc *zap.Logger, ) *Handler { return &Handler{ + telebirrSvc: telebirrSvc, 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/handlers/telebirr.go b/internal/web_server/handlers/telebirr.go new file mode 100644 index 0000000..c715484 --- /dev/null +++ b/internal/web_server/handlers/telebirr.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "strconv" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/gofiber/fiber/v2" +) + +// CreateTelebirrPaymentHandler initializes a payment session with Telebirr. +// +// @Summary Create Telebirr Payment Session +// @Description Generates a payment URL using Telebirr and returns it to the client. +// @Tags Telebirr +// @Accept json +// @Produce json +// @Param request body domain.GeneratePaymentURLInput true "Telebirr payment request payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/telebirr/payment [post] +func (h *Handler) CreateTelebirrPaymentHandler(c *fiber.Ctx) error { + var req domain.TelebirrPreOrderRequestPayload + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Error: err.Error(), + Message: "Invalid request payload", + }) + } + totalAmount, err := strconv.ParseFloat(req.BizContent.TotalAmount, 32) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Error: err.Error(), + Message: "TotalAmount must be a valid number", + }) + } + userID, ok := c.Locals("user_id").(int64) + if !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Error: "Invalid user_id type", + Message: "user_id must be an int64", + }) + } + paymentURL, err := h.telebirrSvc.CreateTelebirrOrder(req.BizContent.Title, float32(totalAmount), userID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Error: err.Error(), + Message: "Failed to create Telebirr payment session", + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Telebirr payment URL generated successfully", + Data: paymentURL, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// HandleTelebirrCallbackHandler handles the Telebirr payment callback. +// +// @Summary Handle Telebirr Payment Callback +// @Description Processes the Telebirr payment result and updates wallet balance. +// @Tags Telebirr +// @Accept json +// @Produce json +// @Param payload body domain.TelebirrPaymentCallbackPayload true "Callback payload from Telebirr" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/telebirr/callback [post] +func (h *Handler) HandleTelebirrCallback(c *fiber.Ctx) error { + var payload domain.TelebirrPaymentCallbackPayload + + if err := c.BodyParser(&payload); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Error: err.Error(), + Message: "Invalid callback payload", + }) + } + + ctx := c.Context() + + err := h.telebirrSvc.HandleTelebirrPaymentCallback(ctx, &payload) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Error: err.Error(), + Message: "Failed to handle Telebirr payment callback", + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Telebirr payment processed successfully", + Data: nil, + Success: true, + StatusCode: fiber.StatusOK, + }) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index b96e163..8311bb5 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -20,7 +20,9 @@ import ( func (a *App) initAppRoutes() { h := handlers.New( + a.telebirrSvc, a.arifpaySvc, + a.santimpaySvc, a.issueReportingSvc, a.instSvc, a.currSvc, @@ -60,10 +62,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", @@ -107,7 +117,17 @@ func (a *App) initAppRoutes() { 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) - + + //Telebirr + groupV1.Post("/telebirr/init-payment", a.authMiddleware, h.CreateTelebirrPaymentHandler) + groupV1.Post("/telebirr/callback", h.HandleTelebirrCallback) + + //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)