package chapa import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "math" "net/http" "strconv" "strings" "time" "Yimaru-Backend/internal/config" "Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/ports" "github.com/google/uuid" ) var ( ErrPaymentNotFound = errors.New("payment not found") ErrPaymentAlreadyPaid = errors.New("payment already processed") ErrInvalidPaymentState = errors.New("invalid payment state") ErrInvalidWebhook = errors.New("invalid webhook signature") ErrChapaNotConfigured = errors.New("chapa is not configured") ) type Service struct { cfg *config.Config httpClient *http.Client paymentStore ports.PaymentStore subscriptionStore ports.SubscriptionStore userStore ports.UserStore } func NewService( cfg *config.Config, httpClient *http.Client, paymentStore ports.PaymentStore, subscriptionStore ports.SubscriptionStore, userStore ports.UserStore, ) *Service { return &Service{ cfg: cfg, httpClient: httpClient, paymentStore: paymentStore, subscriptionStore: subscriptionStore, userStore: userStore, } } func (s *Service) configured() error { if s.cfg.CHAPA_SECRET_KEY == "" { return ErrChapaNotConfigured } return nil } // InitiateSubscriptionPayment creates a Chapa checkout session for a subscription plan. func (s *Service) InitiateSubscriptionPayment(ctx context.Context, userID int64, req domain.InitiateSubscriptionPaymentRequest) (*domain.InitiateSubscriptionPaymentResponse, error) { if err := s.configured(); err != nil { return nil, err } plan, err := s.subscriptionStore.GetSubscriptionPlanByID(ctx, req.PlanID) if err != nil { return nil, fmt.Errorf("failed to get subscription plan: %w", err) } if !plan.IsActive { return nil, errors.New("subscription plan is not active") } hasActive, err := s.subscriptionStore.HasActiveSubscription(ctx, userID) if err != nil { return nil, fmt.Errorf("failed to check active subscription: %w", err) } if hasActive { return nil, errors.New("user already has an active subscription") } user, err := s.userStore.GetUserByID(ctx, userID) if err != nil { return nil, fmt.Errorf("failed to get user: %w", err) } firstName := strings.TrimSpace(user.FirstName) lastName := strings.TrimSpace(user.LastName) if firstName == "" { firstName = "Customer" } if lastName == "" { lastName = "User" } email := strings.TrimSpace(req.Email) if email == "" { email = user.Email } if email == "" { return nil, errors.New("email is required for payment") } phone := formatChapaPhone(req.Phone) if phone == "" && user.PhoneNumber != "" { phone = formatChapaPhone(user.PhoneNumber) } txRef := uuid.NewString() expiresAt := time.Now().Add(3 * time.Hour) payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{ UserID: userID, PlanID: &req.PlanID, Amount: plan.Price, Currency: plan.Currency, Nonce: txRef, ExpiresAt: &expiresAt, }) if err != nil { return nil, fmt.Errorf("failed to create payment record: %w", err) } initReq := domain.ChapaInitializeRequest{ Amount: formatAmount(plan.Price), Currency: normalizeCurrency(plan.Currency), Email: email, FirstName: firstName, LastName: lastName, PhoneNumber: phone, TxRef: txRef, CallbackURL: s.cfg.CHAPA_CALLBACK_URL, ReturnURL: s.cfg.CHAPA_RETURN_URL, } initReq.Customization.Title = "Yimaru LMS" initReq.Customization.Description = fmt.Sprintf("Subscription: %s", plan.Name) checkoutURL, err := s.initializeTransaction(ctx, initReq) if err != nil { return nil, err } if err := s.paymentStore.UpdatePaymentSessionID(ctx, payment.ID, txRef, checkoutURL); err != nil { return nil, fmt.Errorf("failed to update payment session: %w", err) } return &domain.InitiateSubscriptionPaymentResponse{ PaymentID: payment.ID, SessionID: txRef, PaymentURL: checkoutURL, Amount: plan.Price, Currency: plan.Currency, ExpiresAt: expiresAt.Format(time.RFC3339), }, nil } func (s *Service) initializeTransaction(ctx context.Context, req domain.ChapaInitializeRequest) (string, error) { payload, err := json.Marshal(req) if err != nil { return "", fmt.Errorf("failed to marshal initialize request: %w", err) } url := strings.TrimSuffix(s.cfg.CHAPA_BASE_URL, "/") + "/transaction/initialize" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(payload)) if err != nil { return "", err } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+s.cfg.CHAPA_SECRET_KEY) resp, err := s.httpClient.Do(httpReq) if err != nil { return "", fmt.Errorf("failed to call Chapa API: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", err } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("Chapa API error (status %d): %s", resp.StatusCode, string(body)) } var result domain.ChapaInitializeResponse if err := json.Unmarshal(body, &result); err != nil { return "", fmt.Errorf("invalid response from Chapa: %w", err) } if strings.ToLower(result.Status) != "success" || result.Data.CheckoutURL == "" { return "", fmt.Errorf("Chapa initialize failed: %s", result.Message) } return result.Data.CheckoutURL, nil } // VerifyWebhookSignature validates x-chapa-signature or chapa-signature headers. func (s *Service) VerifyWebhookSignature(body []byte, signatures ...string) error { secret := s.cfg.CHAPA_WEBHOOK_SECRET if secret == "" { secret = s.cfg.CHAPA_SECRET_KEY } if secret == "" { return ErrInvalidWebhook } mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) for _, sig := range signatures { sig = strings.TrimSpace(sig) if sig != "" && hmac.Equal([]byte(expected), []byte(sig)) { return nil } } return ErrInvalidWebhook } // ProcessPaymentWebhook handles Chapa webhook events (charge.success, etc.). func (s *Service) ProcessPaymentWebhook(ctx context.Context, payload domain.ChapaWebhookPayload) error { if payload.TxRef == "" { return errors.New("tx_ref is required") } // Always verify with Chapa before granting subscription access. verifyData, err := s.fetchVerifiedTransaction(ctx, payload.TxRef) if err != nil { return err } return s.applyVerifiedTransaction(ctx, verifyData) } // ProcessCallback handles the redirect callback query and verifies the transaction. func (s *Service) ProcessCallback(ctx context.Context, query domain.ChapaCallbackQuery) error { txRef := query.TrxRef if txRef == "" { return errors.New("trx_ref is required") } verifyData, err := s.fetchVerifiedTransaction(ctx, txRef) if err != nil { return err } return s.applyVerifiedTransaction(ctx, verifyData) } // VerifyPayment checks payment status with Chapa using tx_ref (stored as nonce / session_id). func (s *Service) VerifyPayment(ctx context.Context, txRef string) (*domain.Payment, error) { if err := s.configured(); err != nil { return nil, err } payment, err := s.lookupPayment(ctx, txRef) if err != nil { return nil, ErrPaymentNotFound } if payment.Status == string(domain.PaymentStatusSuccess) || payment.Status == string(domain.PaymentStatusFailed) { return payment, nil } verifyData, err := s.fetchVerifiedTransaction(ctx, payment.Nonce) if err != nil { return nil, err } if err := s.applyVerifiedTransaction(ctx, verifyData); err != nil && !errors.Is(err, ErrPaymentAlreadyPaid) { return nil, err } return s.lookupPayment(ctx, txRef) } func (s *Service) fetchVerifiedTransaction(ctx context.Context, txRef string) (domain.ChapaTransactionData, error) { url := strings.TrimSuffix(s.cfg.CHAPA_BASE_URL, "/") + "/transaction/verify/" + txRef httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return domain.ChapaTransactionData{}, err } httpReq.Header.Set("Authorization", "Bearer "+s.cfg.CHAPA_SECRET_KEY) resp, err := s.httpClient.Do(httpReq) if err != nil { return domain.ChapaTransactionData{}, fmt.Errorf("failed to verify with Chapa: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return domain.ChapaTransactionData{}, err } if resp.StatusCode != http.StatusOK { return domain.ChapaTransactionData{}, fmt.Errorf("Chapa verify API error (status %d): %s", resp.StatusCode, string(body)) } var result domain.ChapaVerifyResponse if err := json.Unmarshal(body, &result); err != nil { return domain.ChapaTransactionData{}, fmt.Errorf("failed to parse Chapa verify response: %w", err) } if strings.ToLower(result.Status) != "success" { return domain.ChapaTransactionData{}, fmt.Errorf("Chapa verify failed: %s", result.Message) } return result.Data, nil } func (s *Service) applyVerifiedTransaction(ctx context.Context, data domain.ChapaTransactionData) error { if data.TxRef == "" { return errors.New("tx_ref missing in verified transaction") } payment, err := s.paymentStore.GetPaymentByNonce(ctx, data.TxRef) if err != nil { return fmt.Errorf("payment not found for tx_ref %s: %w", data.TxRef, err) } if payment.Status == string(domain.PaymentStatusSuccess) { return ErrPaymentAlreadyPaid } newStatus := mapChapaStatus(data.Status) transactionID := data.Reference paymentMethod := data.PaymentMethod if paymentMethod == "" { paymentMethod = "chapa" } if err := s.paymentStore.UpdatePaymentStatusByNonce( ctx, data.TxRef, newStatus, transactionID, paymentMethod, ); err != nil { return fmt.Errorf("failed to update payment status: %w", err) } if newStatus != string(domain.PaymentStatusSuccess) || payment.PlanID == nil { return nil } return s.activateSubscription(ctx, payment, paymentMethod) } func (s *Service) activateSubscription(ctx context.Context, payment *domain.Payment, paymentMethod string) error { plan, err := s.subscriptionStore.GetSubscriptionPlanByID(ctx, *payment.PlanID) if err != nil { return fmt.Errorf("failed to get subscription plan: %w", err) } startsAt := time.Now() expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit) activeStatus := string(domain.SubscriptionStatusActive) autoRenew := false paymentRef := payment.Nonce subscription, err := s.subscriptionStore.CreateUserSubscription(ctx, domain.CreateUserSubscriptionInput{ UserID: payment.UserID, PlanID: *payment.PlanID, StartsAt: &startsAt, ExpiresAt: expiresAt, Status: &activeStatus, PaymentReference: &paymentRef, PaymentMethod: &paymentMethod, AutoRenew: &autoRenew, }) if err != nil { return fmt.Errorf("failed to create subscription: %w", err) } if err := s.paymentStore.LinkPaymentToSubscription(ctx, payment.ID, subscription.ID); err != nil { return fmt.Errorf("failed to link payment to subscription: %w", err) } return nil } func (s *Service) lookupPayment(ctx context.Context, ref string) (*domain.Payment, error) { payment, err := s.paymentStore.GetPaymentByNonce(ctx, ref) if err == nil { return payment, nil } return s.paymentStore.GetPaymentBySessionID(ctx, ref) } func (s *Service) GetPaymentsByUser(ctx context.Context, userID int64, limit, offset int32) ([]domain.Payment, error) { return s.paymentStore.GetPaymentsByUserID(ctx, userID, limit, offset) } func (s *Service) GetPaymentByID(ctx context.Context, id int64) (*domain.Payment, error) { return s.paymentStore.GetPaymentByID(ctx, id) } func (s *Service) CancelPayment(ctx context.Context, paymentID int64, userID int64) error { payment, err := s.paymentStore.GetPaymentByID(ctx, paymentID) if err != nil { return ErrPaymentNotFound } if payment.UserID != userID { return errors.New("unauthorized") } if payment.Status != string(domain.PaymentStatusPending) { return ErrInvalidPaymentState } return s.paymentStore.UpdatePaymentStatus(ctx, paymentID, string(domain.PaymentStatusCancelled)) } func (s *Service) GetPaymentMethods() []domain.ChapaPaymentMethod { return []domain.ChapaPaymentMethod{ {Name: "telebirr", DisplayName: "Telebirr"}, {Name: "cbebirr", DisplayName: "CBE Birr"}, {Name: "mpesa", DisplayName: "M-Pesa"}, {Name: "ebirr", DisplayName: "E-Birr"}, {Name: "amole", DisplayName: "Amole"}, {Name: "awashbirr", DisplayName: "Awash Birr"}, {Name: "enat_bank", DisplayName: "Enat Bank"}, {Name: "card", DisplayName: "Card"}, } } func mapChapaStatus(status string) string { switch strings.ToLower(strings.TrimSpace(status)) { case "success", "successful", "completed": return string(domain.PaymentStatusSuccess) case "failed", "failure": return string(domain.PaymentStatusFailed) case "cancelled", "canceled": return string(domain.PaymentStatusCancelled) case "pending", "processing": return string(domain.PaymentStatusProcessing) default: return string(domain.PaymentStatusPending) } } func formatAmount(amount float64) string { return strconv.FormatFloat(math.Round(amount*100)/100, 'f', 2, 64) } func normalizeCurrency(currency string) string { c := strings.TrimSpace(strings.ToUpper(currency)) if c == "" { return "ETB" } return c } func formatChapaPhone(phone string) string { phone = strings.TrimSpace(phone) phone = strings.TrimPrefix(phone, "+") if strings.HasPrefix(phone, "251") && len(phone) >= 12 { local := phone[3:] if strings.HasPrefix(local, "9") || strings.HasPrefix(local, "7") { return "0" + local } } if strings.HasPrefix(phone, "09") || strings.HasPrefix(phone, "07") { return phone } if strings.HasPrefix(phone, "9") || strings.HasPrefix(phone, "7") { return "0" + phone } return phone }