Yimaru-BackEnd/internal/services/chapa/service.go
Yared Yemane 79fb95ce36 Add category-based subscription controls for LMS and exam prep.
Introduce plan and content categories across programs and exam-prep catalog roots, wire category-aware checkout and access checks, and keep learner gating temporarily bypassed until data migration is ready.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 06:20:49 -07:00

481 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.HasActiveSubscriptionByCategory(ctx, userID, plan.Category)
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 for this category")
}
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,
PaymentMethod: func() *string {
v := string(domain.PaymentProviderChapa)
return &v
}(),
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) LookupPayment(ctx context.Context, ref string) (*domain.Payment, error) {
return s.lookupPayment(ctx, ref)
}
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
}