Introduce plan and content categories across programs and exam-prep catalog roots, wire category-aware checkout and access checks, and keep learner gating temporarily bypassed until data migration is ready. Co-authored-by: Cursor <cursoragent@cursor.com>
680 lines
22 KiB
Go
680 lines
22 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
|
|
"Yimaru-Backend/internal/domain"
|
|
"Yimaru-Backend/internal/services/chapa"
|
|
"strconv"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
// =====================
|
|
// Payment Types
|
|
// =====================
|
|
|
|
type initiatePaymentReq struct {
|
|
PlanID int64 `json:"plan_id" validate:"required"`
|
|
Phone string `json:"phone" validate:"required"`
|
|
Email string `json:"email" validate:"required,email"`
|
|
Provider string `json:"provider" validate:"required"`
|
|
}
|
|
|
|
type paymentRes struct {
|
|
ID int64 `json:"id"`
|
|
UserID int64 `json:"user_id"`
|
|
PlanID *int64 `json:"plan_id,omitempty"`
|
|
SubscriptionID *int64 `json:"subscription_id,omitempty"`
|
|
SessionID *string `json:"session_id,omitempty"`
|
|
Amount float64 `json:"amount"`
|
|
Currency string `json:"currency"`
|
|
PaymentMethod *string `json:"payment_method,omitempty"`
|
|
Status string `json:"status"`
|
|
PaymentURL *string `json:"payment_url,omitempty"`
|
|
PlanName *string `json:"plan_name,omitempty"`
|
|
PaidAt *string `json:"paid_at,omitempty"`
|
|
ExpiresAt *string `json:"expires_at,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// =====================
|
|
// Subscription Payment Handlers
|
|
// =====================
|
|
|
|
// InitiateSubscriptionPayment godoc
|
|
// @Summary Initiate subscription payment
|
|
// @Description Creates a payment session for a subscription plan
|
|
// @Tags payments
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body initiatePaymentReq true "Payment request"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 500 {object} domain.ErrorResponse
|
|
// @Router /api/v1/payments/subscribe [post]
|
|
func (h *Handler) InitiateSubscriptionPayment(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").(int64)
|
|
if !ok {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
|
Message: "Unauthorized",
|
|
})
|
|
}
|
|
|
|
var req initiatePaymentReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid request body",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
provider, err := domain.ParsePaymentProvider(req.Provider)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid payment provider",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
result, err := h.initiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{
|
|
PlanID: req.PlanID,
|
|
Phone: req.Phone,
|
|
Email: req.Email,
|
|
Provider: provider,
|
|
})
|
|
if err != nil {
|
|
status := paymentInitiationStatus(err)
|
|
return c.Status(status).JSON(domain.ErrorResponse{
|
|
Message: "Failed to initiate payment",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(domain.Response{
|
|
Message: "Payment initiated successfully",
|
|
Data: result,
|
|
})
|
|
}
|
|
|
|
// VerifyPayment godoc
|
|
// @Summary Verify payment status
|
|
// @Description Checks the payment status with the payment provider
|
|
// @Tags payments
|
|
// @Produce json
|
|
// @Param session_id path string true "Session ID"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 404 {object} domain.ErrorResponse
|
|
// @Router /api/v1/payments/verify/{session_id} [get]
|
|
func (h *Handler) VerifyPayment(c *fiber.Ctx) error {
|
|
sessionID := c.Params("session_id")
|
|
if sessionID == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Session ID is required",
|
|
})
|
|
}
|
|
|
|
payment, err := h.verifyPaymentByProvider(c.Context(), sessionID)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
|
Message: "Payment not found or verification failed",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
if payment.Status == string(domain.PaymentStatusSuccess) {
|
|
h.sendInAppNotification(payment.UserID, domain.NOTIFICATION_TYPE_PAYMENT_VERIFIED, "Payment Successful", "Your payment has been verified successfully. Your subscription is now active.")
|
|
}
|
|
|
|
return c.JSON(domain.Response{
|
|
Message: "Payment status retrieved",
|
|
Data: paymentToRes(payment),
|
|
})
|
|
}
|
|
|
|
// GetMyPayments godoc
|
|
// @Summary Get payment history
|
|
// @Description Returns the authenticated user's payment history
|
|
// @Tags payments
|
|
// @Produce json
|
|
// @Param limit query int false "Limit" default(20)
|
|
// @Param offset query int false "Offset" default(0)
|
|
// @Success 200 {object} domain.Response
|
|
// @Router /api/v1/payments [get]
|
|
func (h *Handler) GetMyPayments(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").(int64)
|
|
if !ok {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
|
Message: "Unauthorized",
|
|
})
|
|
}
|
|
|
|
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
|
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
|
|
|
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",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
result := make([]paymentRes, len(payments))
|
|
for i, p := range payments {
|
|
result[i] = *paymentToRes(&p)
|
|
}
|
|
|
|
return c.JSON(domain.Response{
|
|
Message: "Payment history retrieved successfully",
|
|
Data: result,
|
|
})
|
|
}
|
|
|
|
// GetPaymentByID godoc
|
|
// @Summary Get payment details
|
|
// @Description Returns details of a specific payment
|
|
// @Tags payments
|
|
// @Produce json
|
|
// @Param id path int true "Payment ID"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 404 {object} domain.ErrorResponse
|
|
// @Router /api/v1/payments/{id} [get]
|
|
func (h *Handler) GetPaymentByID(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").(int64)
|
|
if !ok {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
|
Message: "Unauthorized",
|
|
})
|
|
}
|
|
|
|
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid payment ID",
|
|
})
|
|
}
|
|
|
|
payment, err := h.chapaSvc.GetPaymentByID(c.Context(), id)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
|
Message: "Payment not found",
|
|
})
|
|
}
|
|
|
|
if payment.UserID != userID {
|
|
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
|
|
Message: "Access denied",
|
|
})
|
|
}
|
|
|
|
return c.JSON(domain.Response{
|
|
Message: "Payment retrieved successfully",
|
|
Data: paymentToRes(payment),
|
|
})
|
|
}
|
|
|
|
// CancelPayment godoc
|
|
// @Summary Cancel a pending payment
|
|
// @Description Cancels a payment that is still pending
|
|
// @Tags payments
|
|
// @Produce json
|
|
// @Param id path int true "Payment ID"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Router /api/v1/payments/{id}/cancel [post]
|
|
func (h *Handler) CancelPayment(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").(int64)
|
|
if !ok {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
|
Message: "Unauthorized",
|
|
})
|
|
}
|
|
|
|
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid payment ID",
|
|
})
|
|
}
|
|
|
|
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(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(domain.Response{
|
|
Message: "Payment cancelled successfully",
|
|
})
|
|
}
|
|
|
|
func (h *Handler) initiateSubscriptionPayment(ctx context.Context, userID int64, req domain.InitiateSubscriptionPaymentRequest) (*domain.InitiateSubscriptionPaymentResponse, error) {
|
|
switch req.Provider {
|
|
case domain.PaymentProviderChapa:
|
|
return h.chapaSvc.InitiateSubscriptionPayment(ctx, userID, req)
|
|
case domain.PaymentProviderArifPay:
|
|
return h.arifpaySvc.InitiateSubscriptionPayment(ctx, userID, req)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported payment provider %q", req.Provider)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) verifyPaymentByProvider(ctx context.Context, ref string) (*domain.Payment, error) {
|
|
payment, err := h.chapaSvc.LookupPayment(ctx, ref)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if payment.Status == string(domain.PaymentStatusSuccess) ||
|
|
payment.Status == string(domain.PaymentStatusFailed) ||
|
|
payment.Status == string(domain.PaymentStatusCancelled) ||
|
|
payment.Status == string(domain.PaymentStatusExpired) {
|
|
return payment, nil
|
|
}
|
|
|
|
if payment.PaymentMethod != nil {
|
|
if provider, err := domain.ParsePaymentProvider(*payment.PaymentMethod); err == nil {
|
|
switch provider {
|
|
case domain.PaymentProviderChapa:
|
|
return h.chapaSvc.VerifyPayment(ctx, ref)
|
|
case domain.PaymentProviderArifPay:
|
|
return h.arifpaySvc.VerifyPayment(ctx, ref)
|
|
}
|
|
}
|
|
}
|
|
|
|
chapaPayment, chapaErr := h.chapaSvc.VerifyPayment(ctx, ref)
|
|
if chapaErr == nil {
|
|
return chapaPayment, nil
|
|
}
|
|
arifpayPayment, arifpayErr := h.arifpaySvc.VerifyPayment(ctx, ref)
|
|
if arifpayErr == nil {
|
|
return arifpayPayment, nil
|
|
}
|
|
return nil, fmt.Errorf("chapa verify failed: %v; arifpay verify failed: %v", chapaErr, arifpayErr)
|
|
}
|
|
|
|
func paymentInitiationStatus(err error) int {
|
|
switch {
|
|
case errors.Is(err, chapa.ErrChapaNotConfigured):
|
|
return fiber.StatusServiceUnavailable
|
|
case err.Error() == "user already has an active subscription":
|
|
return fiber.StatusConflict
|
|
case err.Error() == "subscription plan is not active":
|
|
return fiber.StatusBadRequest
|
|
default:
|
|
return fiber.StatusInternalServerError
|
|
}
|
|
}
|
|
|
|
// HandleArifpaySuccessPage godoc
|
|
// @Summary ArifPay payment success page
|
|
// @Description Displays the Yimaru Academy success page after ArifPay redirects the learner back to the backend.
|
|
// @Tags payments
|
|
// @Produce html
|
|
// @Param session_id query string false "ArifPay session identifier"
|
|
// @Param sessionId query string false "ArifPay session identifier"
|
|
// @Param nonce query string false "Fallback payment nonce"
|
|
// @Success 200 {string} string "HTML success page"
|
|
// @Router /api/v1/payments/arifpay/success [get]
|
|
func (h *Handler) HandleArifpaySuccessPage(c *fiber.Ctx) error {
|
|
ref := firstNonEmpty(
|
|
c.Query("session_id"),
|
|
c.Query("sessionId"),
|
|
c.Query("sessionID"),
|
|
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: "/",
|
|
}
|
|
|
|
if ref != "" {
|
|
payment, err := h.arifpaySvc.VerifyPayment(c.Context(), ref)
|
|
if err != nil {
|
|
h.logger.Warn("Failed to verify ArifPay success redirect", "error", err, "ref", ref)
|
|
page.Body = "Thank you for your payment. We are confirming it with ArifPay 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 = ref
|
|
} else {
|
|
page.Reference = ref
|
|
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 := renderArifpaySuccessPage(page)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).SendString("Failed to render success page")
|
|
}
|
|
c.Type("html", "utf-8")
|
|
return c.SendString(html)
|
|
}
|
|
|
|
// HandleArifpayWebhook godoc
|
|
// @Summary Handle ArifPay webhook
|
|
// @Description Processes payment notifications from ArifPay
|
|
// @Tags payments
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body domain.WebhookRequest true "Webhook payload"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Router /api/v1/payments/webhook [post]
|
|
func (h *Handler) HandleArifpayWebhook(c *fiber.Ctx) error {
|
|
var req domain.WebhookRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid webhook payload",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
if err := h.arifpaySvc.ProcessPaymentWebhook(c.Context(), req); err != nil {
|
|
h.logger.Error("Failed to process webhook", "error", err, "nonce", req.Nonce)
|
|
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",
|
|
})
|
|
}
|
|
|
|
// GetArifpayPaymentMethods godoc
|
|
// @Summary Get available payment methods
|
|
// @Description Returns list of supported ArifPay payment methods
|
|
// @Tags payments
|
|
// @Produce json
|
|
// @Success 200 {object} domain.Response
|
|
// @Router /api/v1/payments/methods [get]
|
|
func (h *Handler) GetArifpayPaymentMethods(c *fiber.Ctx) error {
|
|
methods := h.arifpaySvc.GetPaymentMethodsMapping()
|
|
|
|
return c.JSON(domain.Response{
|
|
Message: "Payment methods retrieved successfully",
|
|
Data: methods,
|
|
})
|
|
}
|
|
|
|
// =====================
|
|
// Direct Payment Handlers
|
|
// =====================
|
|
|
|
type initiateDirectPaymentReq struct {
|
|
PlanID int64 `json:"plan_id" validate:"required"`
|
|
Phone string `json:"phone" validate:"required"`
|
|
Email string `json:"email" validate:"required,email"`
|
|
PaymentMethod string `json:"payment_method" validate:"required"`
|
|
}
|
|
|
|
type verifyOTPReq struct {
|
|
SessionID string `json:"session_id" validate:"required"`
|
|
OTP string `json:"otp" validate:"required"`
|
|
}
|
|
|
|
// InitiateDirectPayment godoc
|
|
// @Summary Initiate direct payment
|
|
// @Description Creates a payment session and initiates direct payment (OTP-based)
|
|
// @Tags payments
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body initiateDirectPaymentReq true "Direct payment request"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 500 {object} domain.ErrorResponse
|
|
// @Router /api/v1/payments/direct [post]
|
|
func (h *Handler) InitiateDirectPayment(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").(int64)
|
|
if !ok {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
|
Message: "Unauthorized",
|
|
})
|
|
}
|
|
|
|
var req initiateDirectPaymentReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid request body",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
result, err := h.arifpaySvc.InitiateDirectPayment(c.Context(), userID, domain.InitiateDirectPaymentRequest{
|
|
PlanID: req.PlanID,
|
|
Phone: req.Phone,
|
|
Email: req.Email,
|
|
PaymentMethod: domain.DirectPaymentMethod(req.PaymentMethod),
|
|
})
|
|
if err != nil {
|
|
status := fiber.StatusInternalServerError
|
|
if err.Error() == "user already has an active subscription" {
|
|
status = fiber.StatusConflict
|
|
}
|
|
return c.Status(status).JSON(domain.ErrorResponse{
|
|
Message: "Failed to initiate direct payment",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(domain.Response{
|
|
Message: "Direct payment initiated successfully",
|
|
Data: result,
|
|
})
|
|
}
|
|
|
|
// VerifyDirectPaymentOTP godoc
|
|
// @Summary Verify OTP for direct payment
|
|
// @Description Verifies the OTP sent for direct payment methods (Amole, HelloCash, etc.)
|
|
// @Tags payments
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body verifyOTPReq true "OTP verification request"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 404 {object} domain.ErrorResponse
|
|
// @Router /api/v1/payments/direct/verify-otp [post]
|
|
func (h *Handler) VerifyDirectPaymentOTP(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").(int64)
|
|
if !ok {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
|
Message: "Unauthorized",
|
|
})
|
|
}
|
|
|
|
var req verifyOTPReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid request body",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
result, err := h.arifpaySvc.VerifyDirectPaymentOTP(c.Context(), userID, domain.VerifyOTPRequest{
|
|
SessionID: req.SessionID,
|
|
OTP: req.OTP,
|
|
})
|
|
if err != nil {
|
|
status := fiber.StatusInternalServerError
|
|
if err.Error() == "payment not found" {
|
|
status = fiber.StatusNotFound
|
|
}
|
|
return c.Status(status).JSON(domain.ErrorResponse{
|
|
Message: "Failed to verify OTP",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
if !result.Success {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: result.Message,
|
|
})
|
|
}
|
|
|
|
return c.JSON(domain.Response{
|
|
Message: result.Message,
|
|
Data: result,
|
|
})
|
|
}
|
|
|
|
// GetDirectPaymentMethods godoc
|
|
// @Summary Get direct payment methods
|
|
// @Description Returns list of payment methods that support direct payment (OTP-based)
|
|
// @Tags payments
|
|
// @Produce json
|
|
// @Success 200 {object} domain.Response
|
|
// @Router /api/v1/payments/direct/methods [get]
|
|
func (h *Handler) GetDirectPaymentMethods(c *fiber.Ctx) error {
|
|
methods := h.arifpaySvc.GetDirectPaymentMethods()
|
|
|
|
return c.JSON(domain.Response{
|
|
Message: "Direct payment methods retrieved successfully",
|
|
Data: methods,
|
|
})
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func paymentToRes(p *domain.Payment) *paymentRes {
|
|
res := &paymentRes{
|
|
ID: p.ID,
|
|
UserID: p.UserID,
|
|
PlanID: p.PlanID,
|
|
SubscriptionID: p.SubscriptionID,
|
|
SessionID: p.SessionID,
|
|
Amount: p.Amount,
|
|
Currency: p.Currency,
|
|
PaymentMethod: p.PaymentMethod,
|
|
Status: p.Status,
|
|
PaymentURL: p.PaymentURL,
|
|
PlanName: p.PlanName,
|
|
CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
}
|
|
|
|
if p.PaidAt != nil {
|
|
t := p.PaidAt.Format("2006-01-02T15:04:05Z07:00")
|
|
res.PaidAt = &t
|
|
}
|
|
if p.ExpiresAt != nil {
|
|
t := p.ExpiresAt.Format("2006-01-02T15:04:05Z07:00")
|
|
res.ExpiresAt = &t
|
|
}
|
|
|
|
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 = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>{{.Title}}</title>
|
|
</head>
|
|
<body style="margin:0;background:#f4f6fb;font-family:Inter,Roboto,Helvetica,Arial,sans-serif;color:#333;">
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="min-height:100vh;background:#f4f6fb;padding:24px 16px;">
|
|
<tr>
|
|
<td align="center">
|
|
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;width:100%;background:#ffffff;border-radius:18px;overflow:hidden;box-shadow:0 14px 40px rgba(157,42,131,0.12);">
|
|
<tr>
|
|
<td style="background:linear-gradient(135deg,#7b1f6e 0%,#9d2a83 55%,#c43a9a 100%);padding:32px 28px;text-align:center;">
|
|
<div style="display:inline-block;padding:8px 14px;border-radius:999px;background:rgba(255,255,255,0.15);color:#fff;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;">{{.BadgeLabel}}</div>
|
|
<h1 style="margin:18px 0 8px;color:#fff;font-size:30px;line-height:1.2;">Yimaru Academy</h1>
|
|
<p style="margin:0;color:rgba(255,255,255,0.88);font-size:16px;">{{.Headline}}</p>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:32px 28px;">
|
|
<div style="margin:0 auto 24px;width:76px;height:76px;border-radius:50%;background:#eef9f2;border:1px solid #cfead9;text-align:center;line-height:76px;font-size:40px;color:#1f9d55;">✓</div>
|
|
<p style="margin:0 0 18px;font-size:16px;line-height:1.7;color:#555;">{{.Body}}</p>
|
|
{{if .Helper}}<p style="margin:0 0 22px;font-size:14px;line-height:1.7;color:#777;">{{.Helper}}</p>{{end}}
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin:0 0 26px;background:#f8f3f8;border:1px solid #eddced;border-radius:12px;">
|
|
<tr>
|
|
<td style="padding:18px 20px;">
|
|
<p style="margin:0 0 8px;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#9d2a83;">Status</p>
|
|
<p style="margin:0;font-size:18px;font-weight:700;color:#333;">{{.StatusLabel}}</p>
|
|
{{if .PlanName}}<p style="margin:14px 0 0;font-size:14px;color:#555;"><strong>Plan:</strong> {{.PlanName}}</p>{{end}}
|
|
{{if .Reference}}<p style="margin:8px 0 0;font-size:14px;color:#555;word-break:break-word;"><strong>Reference:</strong> {{.Reference}}</p>{{end}}
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
<div style="text-align:center;">
|
|
<a href="{{.ActionHref}}" style="display:inline-block;padding:14px 24px;border-radius:10px;background:#9d2a83;color:#fff;text-decoration:none;font-size:15px;font-weight:700;">{{.ActionLabel}}</a>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:20px 28px;background:#fafafa;border-top:1px solid #eee;text-align:center;">
|
|
<p style="margin:0;font-size:12px;line-height:1.6;color:#8a8a8a;">Yimaru Academy subscription payments are verified securely before access is granted.</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>`
|
|
|
|
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
|
|
}
|