From d3bbd8c95a434481dbc98695487bb9e8c1ecd47b Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Fri, 29 May 2026 04:29:05 -0700 Subject: [PATCH] Add backend Chapa payment success HTML page. Serve /payment/success and /api/v1/payments/chapa/success to verify tx_ref on redirect and activate subscriptions, and share the payment success template with ArifPay. Co-authored-by: Cursor --- docs/CHAPA_INTEGRATION.md | 12 +- internal/web_server/handlers/arifpay.go | 106 +---------------- internal/web_server/handlers/chapa.go | 50 ++++++++ .../handlers/payment_success_page.go | 110 ++++++++++++++++++ internal/web_server/routes.go | 2 + 5 files changed, 171 insertions(+), 109 deletions(-) create mode 100644 internal/web_server/handlers/payment_success_page.go diff --git a/docs/CHAPA_INTEGRATION.md b/docs/CHAPA_INTEGRATION.md index 276a707..43da003 100644 --- a/docs/CHAPA_INTEGRATION.md +++ b/docs/CHAPA_INTEGRATION.md @@ -16,7 +16,7 @@ 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_RETURN_URL=https://your-api.example.com/payment/success CHAPA_RECEIPT_URL= ``` @@ -29,9 +29,9 @@ Configure the same webhook URL in the Chapa dashboard: 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`). +4. After payment, Chapa redirects the learner to `return_url` (`/payment/success`) and calls `callback_url`. +5. The success page and callback both verify via Chapa `GET /transaction/verify/{tx_ref}` and activate the subscription when successful. +6. Chapa also sends a webhook; client may poll `GET /api/v1/payments/verify/{tx_ref}` (`session_id` path param is the `tx_ref`). ## API Endpoints @@ -41,7 +41,9 @@ Configure the same webhook URL in the Chapa dashboard: | 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/chapa/callback` | No | Chapa server callback (JSON) | +| GET | `/api/v1/payments/chapa/success` | No | Chapa learner success page (HTML) | +| GET | `/payment/success` | No | Same HTML success page (`CHAPA_RETURN_URL`) | | GET | `/api/v1/payments/methods` | No | Supported Chapa methods | ### Initiate payment request diff --git a/internal/web_server/handlers/arifpay.go b/internal/web_server/handlers/arifpay.go index b335b74..bd7c2b2 100644 --- a/internal/web_server/handlers/arifpay.go +++ b/internal/web_server/handlers/arifpay.go @@ -1,11 +1,9 @@ package handlers import ( - "bytes" "context" "errors" "fmt" - "html/template" "Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/services/chapa" @@ -332,15 +330,7 @@ func (h *Handler) HandleArifpaySuccessPage(c *fiber.Ctx) error { c.Query("nonce"), ) - page := arifpaySuccessPageData{ - Title: "Subscription Payment Successful", - Headline: "Your Yimaru Academy payment was received", - Body: "Thank you for your payment. Your subscription is being activated and you can return to Yimaru Academy shortly.", - BadgeLabel: "Payment successful", - StatusLabel: "Activation in progress", - ActionLabel: "Continue learning", - ActionHref: "/", - } + page := defaultPaymentSuccessPage() if ref != "" { payment, err := h.arifpaySvc.VerifyPayment(c.Context(), ref) @@ -364,7 +354,7 @@ func (h *Handler) HandleArifpaySuccessPage(c *fiber.Ctx) error { page.Helper = "Return to Yimaru Academy and refresh your subscription status if you do not see access immediately." } - html, err := renderArifpaySuccessPage(page) + html, err := renderPaymentSuccessPage(page) if err != nil { return c.Status(fiber.StatusInternalServerError).SendString("Failed to render success page") } @@ -585,95 +575,3 @@ func paymentToRes(p *domain.Payment) *paymentRes { return res } - -type arifpaySuccessPageData struct { - Title string - Headline string - Body string - Helper string - BadgeLabel string - StatusLabel string - Reference string - PlanName string - ActionLabel string - ActionHref string -} - -func renderArifpaySuccessPage(data arifpaySuccessPageData) (string, error) { - const tpl = ` - - - - - {{.Title}} - - - - - - -
- - - - - - - - - - -
-
{{.BadgeLabel}}
-

Yimaru Academy

-

{{.Headline}}

-
-
-

{{.Body}}

- {{if .Helper}}

{{.Helper}}

{{end}} - - - - -
-

Status

-

{{.StatusLabel}}

- {{if .PlanName}}

Plan: {{.PlanName}}

{{end}} - {{if .Reference}}

Reference: {{.Reference}}

{{end}} -
- -
-

Yimaru Academy subscription payments are verified securely before access is granted.

-
-
- -` - - t, err := template.New("arifpay-success").Parse(tpl) - if err != nil { - return "", err - } - var buf bytes.Buffer - if err := t.Execute(&buf, data); err != nil { - return "", err - } - return buf.String(), nil -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if value != "" { - return value - } - } - return "" -} - -func derefString(value *string) string { - if value == nil { - return "" - } - return *value -} diff --git a/internal/web_server/handlers/chapa.go b/internal/web_server/handlers/chapa.go index 5ac8ed4..53a7731 100644 --- a/internal/web_server/handlers/chapa.go +++ b/internal/web_server/handlers/chapa.go @@ -99,6 +99,56 @@ func (h *Handler) HandleChapaCallback(c *fiber.Ctx) error { }) } +// HandleChapaSuccessPage godoc +// @Summary Chapa payment success page +// @Description Displays the Yimaru Academy success page after Chapa redirects the learner to return_url +// @Tags payments +// @Produce html +// @Param trx_ref query string false "Chapa transaction reference (tx_ref)" +// @Param tx_ref query string false "Chapa transaction reference" +// @Param ref_id query string false "Chapa reference ID" +// @Param status query string false "Payment status from Chapa redirect" +// @Success 200 {string} string "HTML success page" +// @Router /api/v1/payments/chapa/success [get] +// @Router /payment/success [get] +func (h *Handler) HandleChapaSuccessPage(c *fiber.Ctx) error { + txRef := firstNonEmpty( + c.Query("trx_ref"), + c.Query("tx_ref"), + ) + + page := defaultPaymentSuccessPage() + + if txRef != "" { + payment, err := h.chapaSvc.VerifyPayment(c.Context(), txRef) + if err != nil { + h.logger.Warn("Failed to verify Chapa success redirect", "error", err, "tx_ref", txRef) + page.Body = "Thank you for your payment. We are confirming it with Chapa and will activate your subscription shortly." + page.Helper = "You can safely return to Yimaru Academy. If activation takes longer than expected, refresh the app in a moment." + page.Reference = txRef + } else { + page.Reference = txRef + page.PlanName = derefString(payment.PlanName) + if payment.Status == string(domain.PaymentStatusSuccess) { + page.StatusLabel = "Subscription active" + page.Body = "Your Yimaru Academy subscription is active. You now have access to your learning content." + } else { + page.Body = "Thank you for your payment. We received your success redirect and are finalizing subscription activation." + page.StatusLabel = "Processing confirmation" + } + } + } else { + page.Helper = "Return to Yimaru Academy and refresh your subscription status if you do not see access immediately." + } + + html, err := renderPaymentSuccessPage(page) + if err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("Failed to render success page") + } + c.Type("html", "utf-8") + return c.SendString(html) +} + // GetChapaPaymentMethods godoc // @Summary Get Chapa payment methods // @Description Returns payment methods available on Chapa checkout diff --git a/internal/web_server/handlers/payment_success_page.go b/internal/web_server/handlers/payment_success_page.go new file mode 100644 index 0000000..475bd05 --- /dev/null +++ b/internal/web_server/handlers/payment_success_page.go @@ -0,0 +1,110 @@ +package handlers + +import ( + "bytes" + "html/template" +) + +type paymentSuccessPageData struct { + Title string + Headline string + Body string + Helper string + BadgeLabel string + StatusLabel string + Reference string + PlanName string + ActionLabel string + ActionHref string +} + +func renderPaymentSuccessPage(data paymentSuccessPageData) (string, error) { + const tpl = ` + + + + + {{.Title}} + + + + + + +
+ + + + + + + + + + +
+
{{.BadgeLabel}}
+

Yimaru Academy

+

{{.Headline}}

+
+
+

{{.Body}}

+ {{if .Helper}}

{{.Helper}}

{{end}} + + + + +
+

Status

+

{{.StatusLabel}}

+ {{if .PlanName}}

Plan: {{.PlanName}}

{{end}} + {{if .Reference}}

Reference: {{.Reference}}

{{end}} +
+ +
+

Yimaru Academy subscription payments are verified securely before access is granted.

+
+
+ +` + + t, err := template.New("payment-success").Parse(tpl) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} + +func defaultPaymentSuccessPage() paymentSuccessPageData { + return paymentSuccessPageData{ + Title: "Subscription Payment Successful", + Headline: "Your Yimaru Academy payment was received", + Body: "Thank you for your payment. Your subscription is being activated and you can return to Yimaru Academy shortly.", + BadgeLabel: "Payment successful", + StatusLabel: "Activation in progress", + ActionLabel: "Continue learning", + ActionHref: "/", + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func derefString(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index e17fd61..9ca67f1 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -274,7 +274,9 @@ func (a *App) initAppRoutes() { groupV1.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment) groupV1.Post("/payments/webhook", h.HandleChapaWebhook) groupV1.Get("/payments/arifpay/success", h.HandleArifpaySuccessPage) + groupV1.Get("/payments/chapa/success", h.HandleChapaSuccessPage) groupV1.Get("/payments/chapa/callback", h.HandleChapaCallback) + a.fiber.Get("/payment/success", h.HandleChapaSuccessPage) // Direct Payments groupV1.Post("/payments/direct", a.authMiddleware, a.RequirePermission("payments.direct_initiate"), h.InitiateDirectPayment)