Yimaru-BackEnd/internal/services/chapa/service.go
Yared Yemane 1f7b38861e Integrate Chapa for learner subscription payments
Add Chapa checkout, verify, webhook, and callback flows so subscriptions activate only after confirmed payment. Route subscription checkout through Chapa while keeping ArifPay for direct payments. Include integration docs and a Postman collection.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 03:35:57 -07:00

473 lines
14 KiB
Go

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
}