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>
481 lines
14 KiB
Go
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
|
|
}
|