From 1f7b38861e164e7785077c2b3b4dbf852c781341 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Thu, 21 May 2026 03:35:57 -0700 Subject: [PATCH] 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 --- cmd/main.go | 13 +- docs/CHAPA_INTEGRATION.md | 83 +++ internal/domain/chapa.go | 67 +++ internal/services/chapa/service.go | 472 +++++++++++++++ internal/web_server/app.go | 4 + internal/web_server/handlers/arifpay.go | 17 +- internal/web_server/handlers/chapa.go | 114 ++++ internal/web_server/handlers/handlers.go | 4 + internal/web_server/handlers/subscriptions.go | 7 +- internal/web_server/routes.go | 8 +- ...scription-Payments.postman_collection.json | 543 ++++++++++++++++++ 11 files changed, 1320 insertions(+), 12 deletions(-) create mode 100644 docs/CHAPA_INTEGRATION.md create mode 100644 internal/domain/chapa.go create mode 100644 internal/services/chapa/service.go create mode 100644 internal/web_server/handlers/chapa.go create mode 100644 postman/Chapa-Subscription-Payments.postman_collection.json 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": [] + } + ] + } + ] +}