diff --git a/cmd/main.go b/cmd/main.go index 2590b5a..466acde 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -14,6 +14,7 @@ import ( "Yimaru-Backend/internal/repository" activitylogservice "Yimaru-Backend/internal/services/activity_log" "Yimaru-Backend/internal/services/arifpay" + "Yimaru-Backend/internal/services/chapa" "Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/authentication" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" @@ -419,7 +420,7 @@ func main() { // Subscriptions service subscriptionsSvc := subscriptions.NewService(store) - // ArifPay service with payment and subscription stores + // ArifPay service (direct/legacy payment flows) arifpaySvc := arifpay.NewArifpayService( cfg, &http.Client{Timeout: 30 * time.Second}, @@ -427,6 +428,15 @@ func main() { store, // implements SubscriptionStore ) + // Chapa service for subscription checkout payments + chapaSvc := chapa.NewService( + cfg, + &http.Client{Timeout: 30 * time.Second}, + store, + store, + store, + ) + // Team management service teamSvc := team.NewService(repository.NewTeamStore(store), cfg.RefreshExpiry) @@ -468,6 +478,7 @@ func main() { practiceSvc, subscriptionsSvc, arifpaySvc, + chapaSvc, issueReportingSvc, vimeoSvc, teamSvc, diff --git a/docs/CHAPA_INTEGRATION.md b/docs/CHAPA_INTEGRATION.md new file mode 100644 index 0000000..276a707 --- /dev/null +++ b/docs/CHAPA_INTEGRATION.md @@ -0,0 +1,83 @@ +# Chapa Payment Gateway Integration + +Subscription payments for learners use [Chapa](https://developer.chapa.co/docs) hosted checkout, following the same payment-first flow as the previous ArifPay integration. + +## Overview + +- Subscriptions are created only after Chapa confirms payment (webhook and/or verify). +- `tx_ref` is stored as the payment `nonce` and returned as `session_id` in API responses. +- ArifPay direct-payment routes remain available for legacy flows; subscription checkout uses Chapa. + +## Environment Variables + +```env +CHAPA_SECRET_KEY=CHASECK_TEST-xxxxxxxx +CHAPA_PUBLIC_KEY=CHAPUBK_TEST-xxxxxxxx +CHAPA_WEBHOOK_SECRET=your_webhook_secret_from_dashboard +CHAPA_BASE_URL=https://api.chapa.co/v1 +CHAPA_CALLBACK_URL=https://your-api.example.com/api/v1/payments/chapa/callback +CHAPA_RETURN_URL=https://your-app.example.com/payment/success +CHAPA_RECEIPT_URL= +``` + +Configure the same webhook URL in the Chapa dashboard: + +`https://your-api.example.com/api/v1/payments/webhook` + +## Payment Flow + +1. Learner calls `POST /api/v1/subscriptions/checkout` or `POST /api/v1/payments/subscribe`. +2. Backend creates a pending payment and calls Chapa `POST /transaction/initialize`. +3. Client redirects the user to `payment_url` (`checkout_url` from Chapa). +4. After payment, Chapa calls `callback_url` and sends a webhook. +5. Backend verifies via `GET /transaction/verify/{tx_ref}` and activates the subscription. +6. Client may poll `GET /api/v1/payments/verify/{tx_ref}` (`session_id` path param is the `tx_ref`). + +## API Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/v1/subscriptions/checkout` | Yes | Initiate subscription payment | +| POST | `/api/v1/payments/subscribe` | Yes | Same as checkout | +| GET | `/api/v1/payments/verify/:session_id` | Yes | Verify by `tx_ref` | +| POST | `/api/v1/payments/webhook` | No | Chapa webhook (HMAC signature required) | +| GET | `/api/v1/payments/chapa/callback` | No | Chapa redirect callback | +| GET | `/api/v1/payments/methods` | No | Supported Chapa methods | + +### Initiate payment request + +```json +{ + "plan_id": 1, + "phone": "0912345678", + "email": "learner@example.com" +} +``` + +### Initiate payment response + +```json +{ + "message": "Payment initiated. Complete payment to activate subscription.", + "data": { + "payment_id": 42, + "session_id": "550e8400-e29b-41d4-a716-446655440000", + "payment_url": "https://checkout.chapa.co/checkout/payment/...", + "amount": 500, + "currency": "ETB", + "expires_at": "2026-05-21T18:00:00Z" + } +} +``` + +## Webhook Security + +Chapa signs the raw JSON body with HMAC-SHA256 using your webhook secret. The handler checks `x-chapa-signature` or `chapa-signature` before processing. + +## Testing + +Use Chapa test keys and [test credentials](https://developer.chapa.co/test/testing-mobile). After checkout, confirm the subscription via verify endpoint or webhook logs. + +### Postman + +Import `postman/Chapa-Subscription-Payments.postman_collection.json`. Set collection variables (`base_url`, learner credentials, `chapa_webhook_secret`), then run folders **00 → 02** in order. diff --git a/internal/domain/chapa.go b/internal/domain/chapa.go new file mode 100644 index 0000000..758279e --- /dev/null +++ b/internal/domain/chapa.go @@ -0,0 +1,67 @@ +package domain + +// ChapaInitializeRequest is sent to POST /transaction/initialize. +type ChapaInitializeRequest struct { + Amount string `json:"amount"` + Currency string `json:"currency"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + PhoneNumber string `json:"phone_number,omitempty"` + TxRef string `json:"tx_ref"` + CallbackURL string `json:"callback_url,omitempty"` + ReturnURL string `json:"return_url,omitempty"` + Customization struct { + Title string `json:"title"` + Description string `json:"description"` + } `json:"customization,omitempty"` +} + +type ChapaInitializeResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data struct { + CheckoutURL string `json:"checkout_url"` + } `json:"data"` +} + +type ChapaVerifyResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data ChapaTransactionData `json:"data"` +} + +type ChapaTransactionData struct { + TxRef string `json:"tx_ref"` + Reference string `json:"reference"` + Amount string `json:"amount"` + Currency string `json:"currency"` + Status string `json:"status"` + PaymentMethod string `json:"payment_method"` + Mode string `json:"mode"` +} + +// ChapaWebhookPayload is the body POSTed to the webhook URL. +type ChapaWebhookPayload struct { + Event string `json:"event"` + Type string `json:"type"` + TxRef string `json:"tx_ref"` + Reference string `json:"reference"` + Status string `json:"status"` + Amount string `json:"amount"` + Currency string `json:"currency"` + PaymentMethod string `json:"payment_method"` + Mode string `json:"mode"` +} + +// ChapaCallbackQuery is sent to callback_url after payment (GET). +type ChapaCallbackQuery struct { + TrxRef string `json:"trx_ref"` + RefID string `json:"ref_id"` + Status string `json:"status"` +} + +type ChapaPaymentMethod struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` +} diff --git a/internal/services/chapa/service.go b/internal/services/chapa/service.go new file mode 100644 index 0000000..75ca972 --- /dev/null +++ b/internal/services/chapa/service.go @@ -0,0 +1,472 @@ +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 +} diff --git a/internal/web_server/app.go b/internal/web_server/app.go index b11417d..866691f 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -5,6 +5,7 @@ import ( "Yimaru-Backend/internal/config" activitylogservice "Yimaru-Backend/internal/services/activity_log" "Yimaru-Backend/internal/services/arifpay" + "Yimaru-Backend/internal/services/chapa" "Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/authentication" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" @@ -59,6 +60,7 @@ type App struct { practiceSvc *practices.Service subscriptionsSvc *subscriptions.Service arifpaySvc *arifpay.ArifpayService + chapaSvc *chapa.Service issueReportingSvc *issuereporting.Service vimeoSvc *vimeoservice.Service teamSvc *team.Service @@ -99,6 +101,7 @@ func NewApp( practiceSvc *practices.Service, subscriptionsSvc *subscriptions.Service, arifpaySvc *arifpay.ArifpayService, + chapaSvc *chapa.Service, issueReportingSvc *issuereporting.Service, vimeoSvc *vimeoservice.Service, teamSvc *team.Service, @@ -151,6 +154,7 @@ func NewApp( practiceSvc: practiceSvc, subscriptionsSvc: subscriptionsSvc, arifpaySvc: arifpaySvc, + chapaSvc: chapaSvc, vimeoSvc: vimeoSvc, teamSvc: teamSvc, activityLogSvc: activityLogSvc, diff --git a/internal/web_server/handlers/arifpay.go b/internal/web_server/handlers/arifpay.go index 2061ccc..b428d51 100644 --- a/internal/web_server/handlers/arifpay.go +++ b/internal/web_server/handlers/arifpay.go @@ -1,7 +1,10 @@ package handlers import ( + "errors" + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/services/chapa" "strconv" "github.com/gofiber/fiber/v2" @@ -65,14 +68,16 @@ func (h *Handler) InitiateSubscriptionPayment(c *fiber.Ctx) error { }) } - result, err := h.arifpaySvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{ + result, err := h.chapaSvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{ PlanID: req.PlanID, Phone: req.Phone, Email: req.Email, }) if err != nil { status := fiber.StatusInternalServerError - if err.Error() == "user already has an active subscription" { + if errors.Is(err, chapa.ErrChapaNotConfigured) { + status = fiber.StatusServiceUnavailable + } else if err.Error() == "user already has an active subscription" { status = fiber.StatusConflict } return c.Status(status).JSON(domain.ErrorResponse{ @@ -105,7 +110,7 @@ func (h *Handler) VerifyPayment(c *fiber.Ctx) error { }) } - payment, err := h.arifpaySvc.VerifyPayment(c.Context(), sessionID) + payment, err := h.chapaSvc.VerifyPayment(c.Context(), sessionID) if err != nil { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Payment not found or verification failed", @@ -143,7 +148,7 @@ func (h *Handler) GetMyPayments(c *fiber.Ctx) error { limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) - payments, err := h.arifpaySvc.GetPaymentsByUser(c.Context(), userID, int32(limit), int32(offset)) + payments, err := h.chapaSvc.GetPaymentsByUser(c.Context(), userID, int32(limit), int32(offset)) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to get payment history", @@ -186,7 +191,7 @@ func (h *Handler) GetPaymentByID(c *fiber.Ctx) error { }) } - payment, err := h.arifpaySvc.GetPaymentByID(c.Context(), id) + payment, err := h.chapaSvc.GetPaymentByID(c.Context(), id) if err != nil { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Payment not found", @@ -229,7 +234,7 @@ func (h *Handler) CancelPayment(c *fiber.Ctx) error { }) } - if err := h.arifpaySvc.CancelPayment(c.Context(), id, userID); err != nil { + if err := h.chapaSvc.CancelPayment(c.Context(), id, userID); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Failed to cancel payment", Error: err.Error(), diff --git a/internal/web_server/handlers/chapa.go b/internal/web_server/handlers/chapa.go new file mode 100644 index 0000000..5ac8ed4 --- /dev/null +++ b/internal/web_server/handlers/chapa.go @@ -0,0 +1,114 @@ +package handlers + +import ( + "encoding/json" + "errors" + + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/services/chapa" + + "github.com/gofiber/fiber/v2" +) + +// HandleChapaWebhook godoc +// @Summary Handle Chapa webhook +// @Description Processes payment notifications from Chapa (charge.success, etc.) +// @Tags payments +// @Accept json +// @Produce json +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Router /api/v1/payments/webhook [post] +func (h *Handler) HandleChapaWebhook(c *fiber.Ctx) error { + body := c.Body() + signature := c.Get("x-chapa-signature") + if signature == "" { + signature = c.Get("chapa-signature") + } + + if err := h.chapaSvc.VerifyWebhookSignature(body, signature); err != nil { + h.logger.Error("Invalid Chapa webhook signature", "error", err) + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Invalid webhook signature", + }) + } + + var payload domain.ChapaWebhookPayload + if err := json.Unmarshal(body, &payload); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid webhook payload", + Error: err.Error(), + }) + } + + if err := h.chapaSvc.ProcessPaymentWebhook(c.Context(), payload); err != nil { + if errors.Is(err, chapa.ErrPaymentAlreadyPaid) { + return c.JSON(domain.Response{Message: "Webhook already processed"}) + } + h.logger.Error("Failed to process Chapa webhook", "error", err, "tx_ref", payload.TxRef) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to process webhook", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Webhook processed successfully", + }) +} + +// HandleChapaCallback godoc +// @Summary Chapa payment callback +// @Description Verifies payment after Chapa redirects to callback_url +// @Tags payments +// @Produce json +// @Param trx_ref query string false "Transaction reference" +// @Param ref_id query string false "Chapa reference ID" +// @Param status query string false "Payment status" +// @Success 200 {object} domain.Response +// @Router /api/v1/payments/chapa/callback [get] +func (h *Handler) HandleChapaCallback(c *fiber.Ctx) error { + query := domain.ChapaCallbackQuery{ + TrxRef: c.Query("trx_ref"), + RefID: c.Query("ref_id"), + Status: c.Query("status"), + } + if query.TrxRef == "" { + query.TrxRef = c.Query("tx_ref") + } + + if query.TrxRef == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "trx_ref is required", + }) + } + + if err := h.chapaSvc.ProcessCallback(c.Context(), query); err != nil { + if errors.Is(err, chapa.ErrPaymentAlreadyPaid) { + return c.JSON(domain.Response{Message: "Payment already processed"}) + } + h.logger.Error("Failed to process Chapa callback", "error", err, "trx_ref", query.TrxRef) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to process callback", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Callback processed successfully", + }) +} + +// GetChapaPaymentMethods godoc +// @Summary Get Chapa payment methods +// @Description Returns payment methods available on Chapa checkout +// @Tags payments +// @Produce json +// @Success 200 {object} domain.Response +// @Router /api/v1/payments/methods [get] +func (h *Handler) GetChapaPaymentMethods(c *fiber.Ctx) error { + return c.JSON(domain.Response{ + Message: "Payment methods retrieved successfully", + Data: h.chapaSvc.GetPaymentMethods(), + }) +} diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index ad0ac4e..94af31e 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -8,6 +8,7 @@ import ( "Yimaru-Backend/internal/domain" activitylogservice "Yimaru-Backend/internal/services/activity_log" "Yimaru-Backend/internal/services/arifpay" + "Yimaru-Backend/internal/services/chapa" "Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/authentication" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" @@ -58,6 +59,7 @@ type Handler struct { practiceSvc *practices.Service subscriptionsSvc *subscriptions.Service arifpaySvc *arifpay.ArifpayService + chapaSvc *chapa.Service logger *slog.Logger settingSvc *settings.Service notificationSvc *notificationservice.Service @@ -94,6 +96,7 @@ func New( practiceSvc *practices.Service, subscriptionsSvc *subscriptions.Service, arifpaySvc *arifpay.ArifpayService, + chapaSvc *chapa.Service, logger *slog.Logger, settingSvc *settings.Service, notificationSvc *notificationservice.Service, @@ -129,6 +132,7 @@ func New( practiceSvc: practiceSvc, subscriptionsSvc: subscriptionsSvc, arifpaySvc: arifpaySvc, + chapaSvc: chapaSvc, logger: logger, settingSvc: settingSvc, notificationSvc: notificationSvc, diff --git a/internal/web_server/handlers/subscriptions.go b/internal/web_server/handlers/subscriptions.go index e64c47f..8bb79e0 100644 --- a/internal/web_server/handlers/subscriptions.go +++ b/internal/web_server/handlers/subscriptions.go @@ -2,6 +2,7 @@ package handlers import ( "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/services/chapa" subscriptionsvc "Yimaru-Backend/internal/services/subscriptions" "context" "encoding/json" @@ -381,14 +382,16 @@ func (h *Handler) SubscribeWithPayment(c *fiber.Ctx) error { } // Use ArifPay service to initiate payment - result, err := h.arifpaySvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{ + result, err := h.chapaSvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{ PlanID: req.PlanID, Phone: req.Phone, Email: req.Email, }) if err != nil { status := fiber.StatusInternalServerError - if err.Error() == "user already has an active subscription" { + if errors.Is(err, chapa.ErrChapaNotConfigured) { + status = fiber.StatusServiceUnavailable + } else if err.Error() == "user already has an active subscription" { status = fiber.StatusConflict } else if err.Error() == "subscription plan is not active" { status = fiber.StatusBadRequest diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 51f3f5b..e012897 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -26,6 +26,7 @@ func (a *App) initAppRoutes() { a.practiceSvc, a.subscriptionsSvc, a.arifpaySvc, + a.chapaSvc, a.logger, a.settingSvc, a.NotidicationStore, @@ -231,14 +232,15 @@ func (a *App) initAppRoutes() { groupV1.Post("/subscriptions/:id/cancel", a.authMiddleware, a.RequirePermission("subscriptions.cancel"), h.CancelSubscription) groupV1.Put("/subscriptions/:id/auto-renew", a.authMiddleware, a.RequirePermission("subscriptions.set_auto_renew"), h.SetAutoRenew) - // Payments (ArifPay) + // Payments (Chapa) groupV1.Post("/payments/subscribe", a.authMiddleware, a.RequirePermission("payments.initiate"), h.InitiateSubscriptionPayment) groupV1.Get("/payments/verify/:session_id", a.authMiddleware, a.RequirePermission("payments.verify"), h.VerifyPayment) groupV1.Get("/payments", a.authMiddleware, a.RequirePermission("payments.list_mine"), h.GetMyPayments) - groupV1.Get("/payments/methods", h.GetArifpayPaymentMethods) + groupV1.Get("/payments/methods", h.GetChapaPaymentMethods) groupV1.Get("/payments/:id", a.authMiddleware, a.RequirePermission("payments.get"), h.GetPaymentByID) groupV1.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment) - groupV1.Post("/payments/webhook", h.HandleArifpayWebhook) + groupV1.Post("/payments/webhook", h.HandleChapaWebhook) + groupV1.Get("/payments/chapa/callback", h.HandleChapaCallback) // Direct Payments groupV1.Post("/payments/direct", a.authMiddleware, a.RequirePermission("payments.direct_initiate"), h.InitiateDirectPayment) diff --git a/postman/Chapa-Subscription-Payments.postman_collection.json b/postman/Chapa-Subscription-Payments.postman_collection.json new file mode 100644 index 0000000..0600468 --- /dev/null +++ b/postman/Chapa-Subscription-Payments.postman_collection.json @@ -0,0 +1,543 @@ +{ + "info": { + "_postman_id": "c4a8f2e1-9b3d-4c7a-a1e6-chapa-payments-01", + "name": "Chapa Subscription Payments", + "description": "Postman collection for Yimaru LMS Chapa subscription payment flow.\n\n## Setup\n1. Set `base_url` (default `http://localhost:8080`).\n2. Set `learner_email`, `learner_password`, and `learner_phone`.\n3. Set `chapa_webhook_secret` (same as `CHAPA_WEBHOOK_SECRET` in `.env`).\n4. Run **Customer Login** to populate `access_token`.\n5. Run **List Subscription Plans** to populate `plan_id`.\n6. Run **Subscribe with Payment** — open `payment_url` in a browser and complete Chapa test checkout.\n7. Run **Verify Payment** (uses `tx_ref` saved as `session_id`).\n\n## Notes\n- `session_id` in verify/cancel paths is Chapa `tx_ref` (UUID returned at checkout).\n- Webhook request includes a pre-request script that signs the body with HMAC-SHA256.\n- See `docs/CHAPA_INTEGRATION.md` for dashboard webhook URL configuration.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + }, + "variable": [ + { + "key": "base_url", + "value": "http://localhost:8080" + }, + { + "key": "access_token", + "value": "" + }, + { + "key": "learner_email", + "value": "learner@example.com" + }, + { + "key": "learner_password", + "value": "your-password" + }, + { + "key": "learner_phone", + "value": "0912345678" + }, + { + "key": "plan_id", + "value": "1" + }, + { + "key": "payment_id", + "value": "" + }, + { + "key": "tx_ref", + "value": "" + }, + { + "key": "payment_url", + "value": "" + }, + { + "key": "chapa_webhook_secret", + "value": "" + }, + { + "key": "chapa_ref_id", + "value": "APqDvYw1okk2" + } + ], + "item": [ + { + "name": "00 - Auth", + "item": [ + { + "name": "Customer Login", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "const body = pm.response.json();", + "if (body.data && body.data.access_token) {", + " pm.collectionVariables.set('access_token', body.data.access_token);", + " pm.test('Access token saved', function () {", + " pm.expect(body.data.access_token).to.be.a('string').and.not.empty;", + " });", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{learner_email}}\",\n \"password\": \"{{learner_password}}\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/auth/customer-login", + "host": ["{{base_url}}"], + "path": ["api", "v1", "auth", "customer-login"] + }, + "description": "Authenticates a learner and saves `access_token` for subsequent requests." + }, + "response": [] + } + ] + }, + { + "name": "01 - Subscription Plans", + "item": [ + { + "name": "List Subscription Plans", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "const body = pm.response.json();", + "if (Array.isArray(body.data) && body.data.length > 0) {", + " pm.collectionVariables.set('plan_id', String(body.data[0].id));", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/subscription-plans?active_only=true", + "host": ["{{base_url}}"], + "path": ["api", "v1", "subscription-plans"], + "query": [ + { + "key": "active_only", + "value": "true" + } + ] + }, + "description": "Public list of active plans. Saves first plan `id` to `plan_id`." + }, + "response": [] + }, + { + "name": "Get Subscription Plan by ID", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/subscription-plans/{{plan_id}}", + "host": ["{{base_url}}"], + "path": ["api", "v1", "subscription-plans", "{{plan_id}}"] + } + }, + "response": [] + } + ] + }, + { + "name": "02 - Chapa Payment Flow", + "item": [ + { + "name": "Subscribe with Payment (Checkout)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "const body = pm.response.json();", + "if (body.data) {", + " if (body.data.payment_id) {", + " pm.collectionVariables.set('payment_id', String(body.data.payment_id));", + " }", + " if (body.data.session_id) {", + " pm.collectionVariables.set('tx_ref', body.data.session_id);", + " }", + " if (body.data.payment_url) {", + " pm.collectionVariables.set('payment_url', body.data.payment_url);", + " }", + " pm.test('Payment URL returned', function () {", + " pm.expect(body.data.payment_url).to.be.a('string').and.not.empty;", + " });", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"plan_id\": {{plan_id}},\n \"phone\": \"{{learner_phone}}\",\n \"email\": \"{{learner_email}}\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/subscriptions/checkout", + "host": ["{{base_url}}"], + "path": ["api", "v1", "subscriptions", "checkout"] + }, + "description": "Primary learner endpoint. Returns Chapa `payment_url`. Open it in a browser to complete payment. `session_id` in the response is the Chapa `tx_ref`." + }, + "response": [] + }, + { + "name": "Initiate Subscription Payment", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const body = pm.response.json();", + "if (body.data) {", + " if (body.data.payment_id) pm.collectionVariables.set('payment_id', String(body.data.payment_id));", + " if (body.data.session_id) pm.collectionVariables.set('tx_ref', body.data.session_id);", + " if (body.data.payment_url) pm.collectionVariables.set('payment_url', body.data.payment_url);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"plan_id\": {{plan_id}},\n \"phone\": \"{{learner_phone}}\",\n \"email\": \"{{learner_email}}\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/payments/subscribe", + "host": ["{{base_url}}"], + "path": ["api", "v1", "payments", "subscribe"] + }, + "description": "Alias of checkout — same Chapa initialize flow." + }, + "response": [] + }, + { + "name": "Verify Payment", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/payments/verify/{{tx_ref}}", + "host": ["{{base_url}}"], + "path": ["api", "v1", "payments", "verify", "{{tx_ref}}"] + }, + "description": "Verifies payment with Chapa using `tx_ref` (path param named `session_id` in the API). Run after completing checkout." + }, + "response": [] + }, + { + "name": "Get My Payments", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/payments?limit=20&offset=0", + "host": ["{{base_url}}"], + "path": ["api", "v1", "payments"], + "query": [ + { + "key": "limit", + "value": "20" + }, + { + "key": "offset", + "value": "0" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Payment by ID", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/payments/{{payment_id}}", + "host": ["{{base_url}}"], + "path": ["api", "v1", "payments", "{{payment_id}}"] + } + }, + "response": [] + }, + { + "name": "Cancel Pending Payment", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/payments/{{payment_id}}/cancel", + "host": ["{{base_url}}"], + "path": ["api", "v1", "payments", "{{payment_id}}", "cancel"] + }, + "description": "Only works while payment status is PENDING." + }, + "response": [] + }, + { + "name": "Get Chapa Payment Methods", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/payments/methods", + "host": ["{{base_url}}"], + "path": ["api", "v1", "payments", "methods"] + } + }, + "response": [] + } + ] + }, + { + "name": "03 - Subscription Status", + "item": [ + { + "name": "Get My Subscription", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/subscriptions/me", + "host": ["{{base_url}}"], + "path": ["api", "v1", "subscriptions", "me"] + }, + "description": "Returns active subscription after successful payment." + }, + "response": [] + }, + { + "name": "Check Subscription Status", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/subscriptions/status", + "host": ["{{base_url}}"], + "path": ["api", "v1", "subscriptions", "status"] + } + }, + "response": [] + }, + { + "name": "Get Subscription History", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/subscriptions/history", + "host": ["{{base_url}}"], + "path": ["api", "v1", "subscriptions", "history"] + } + }, + "response": [] + } + ] + }, + { + "name": "04 - Chapa Webhooks (no auth)", + "item": [ + { + "name": "Chapa Webhook (charge.success)", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const secret = pm.collectionVariables.get('chapa_webhook_secret') || '';", + "const body = pm.request.body.raw || '';", + "if (!secret) {", + " console.warn('Set chapa_webhook_secret collection variable to sign the webhook');", + "}", + "const signature = CryptoJS.HmacSHA256(body, secret).toString(CryptoJS.enc.Hex);", + "pm.request.headers.upsert({ key: 'x-chapa-signature', value: signature });" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"event\": \"charge.success\",\n \"type\": \"API\",\n \"tx_ref\": \"{{tx_ref}}\",\n \"reference\": \"{{chapa_ref_id}}\",\n \"status\": \"success\",\n \"amount\": \"500.00\",\n \"currency\": \"ETB\",\n \"payment_method\": \"telebirr\",\n \"mode\": \"test\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/payments/webhook", + "host": ["{{base_url}}"], + "path": ["api", "v1", "payments", "webhook"] + }, + "description": "Simulates Chapa webhook. Requires valid `tx_ref` from a real initialize call. Backend re-verifies with Chapa API before activating subscription. Set `chapa_webhook_secret` to match dashboard / `.env`." + }, + "response": [] + }, + { + "name": "Chapa Callback (GET)", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/payments/chapa/callback?trx_ref={{tx_ref}}&ref_id={{chapa_ref_id}}&status=success", + "host": ["{{base_url}}"], + "path": ["api", "v1", "payments", "chapa", "callback"], + "query": [ + { + "key": "trx_ref", + "value": "{{tx_ref}}" + }, + { + "key": "ref_id", + "value": "{{chapa_ref_id}}" + }, + { + "key": "status", + "value": "success" + } + ] + }, + "description": "Simulates Chapa redirect to `CHAPA_CALLBACK_URL`. Uses same verify flow as webhook." + }, + "response": [] + } + ] + }, + { + "name": "05 - ArifPay Direct (legacy)", + "description": "OTP/direct payment flows still use ArifPay. Subscription checkout uses Chapa (folder 02).", + "item": [ + { + "name": "Get Direct Payment Methods", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/payments/direct/methods", + "host": ["{{base_url}}"], + "path": ["api", "v1", "payments", "direct", "methods"] + } + }, + "response": [] + }, + { + "name": "Initiate Direct Payment", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"plan_id\": {{plan_id}},\n \"phone\": \"{{learner_phone}}\",\n \"email\": \"{{learner_email}}\",\n \"payment_method\": \"TELEBIRR\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/payments/direct", + "host": ["{{base_url}}"], + "path": ["api", "v1", "payments", "direct"] + } + }, + "response": [] + }, + { + "name": "Verify Direct Payment OTP", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"session_id\": \"{{tx_ref}}\",\n \"otp\": \"123456\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/payments/direct/verify-otp", + "host": ["{{base_url}}"], + "path": ["api", "v1", "payments", "direct", "verify-otp"] + } + }, + "response": [] + } + ] + } + ] +}