feat: wire payment return url payloads
This commit is contained in:
@@ -202,10 +202,12 @@ func (h *PaymentHandler) GetLimits(c *gin.Context) {
|
||||
|
||||
// CreateOrderRequest is the request body for creating a payment order.
|
||||
type CreateOrderRequest struct {
|
||||
Amount float64 `json:"amount"`
|
||||
PaymentType string `json:"payment_type" binding:"required"`
|
||||
OrderType string `json:"order_type"`
|
||||
PlanID int64 `json:"plan_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
PaymentType string `json:"payment_type" binding:"required"`
|
||||
ReturnURL string `json:"return_url"`
|
||||
PaymentSource string `json:"payment_source"`
|
||||
OrderType string `json:"order_type"`
|
||||
PlanID int64 `json:"plan_id"`
|
||||
}
|
||||
|
||||
// CreateOrder creates a new payment order.
|
||||
@@ -223,15 +225,16 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
|
||||
}
|
||||
|
||||
result, err := h.paymentService.CreateOrder(c.Request.Context(), service.CreateOrderRequest{
|
||||
UserID: subject.UserID,
|
||||
Amount: req.Amount,
|
||||
PaymentType: req.PaymentType,
|
||||
ClientIP: c.ClientIP(),
|
||||
IsMobile: isMobile(c),
|
||||
SrcHost: c.Request.Host,
|
||||
SrcURL: c.Request.Referer(),
|
||||
OrderType: req.OrderType,
|
||||
PlanID: req.PlanID,
|
||||
UserID: subject.UserID,
|
||||
Amount: req.Amount,
|
||||
PaymentType: req.PaymentType,
|
||||
ClientIP: c.ClientIP(),
|
||||
IsMobile: isMobile(c),
|
||||
SrcHost: c.Request.Host,
|
||||
ReturnURL: req.ReturnURL,
|
||||
PaymentSource: req.PaymentSource,
|
||||
OrderType: req.OrderType,
|
||||
PlanID: req.PlanID,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
|
||||
@@ -22,6 +22,9 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
|
||||
if req.OrderType == "" {
|
||||
req.OrderType = payment.OrderTypeBalance
|
||||
}
|
||||
if normalized := NormalizeVisibleMethod(req.PaymentType); normalized != "" {
|
||||
req.PaymentType = normalized
|
||||
}
|
||||
cfg, err := s.configService.GetPaymentConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get payment config: %w", err)
|
||||
@@ -212,7 +215,38 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
|
||||
}
|
||||
subject := s.buildPaymentSubject(plan, limitAmount, cfg)
|
||||
outTradeNo := order.OutTradeNo
|
||||
pr, err := prov.CreatePayment(ctx, payment.CreatePaymentRequest{OrderID: outTradeNo, Amount: payAmountStr, PaymentType: req.PaymentType, Subject: subject, ClientIP: req.ClientIP, IsMobile: req.IsMobile, InstanceSubMethods: sel.SupportedTypes})
|
||||
canonicalReturnURL, err := CanonicalizeReturnURL(req.ReturnURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resumeToken := ""
|
||||
if resume := s.paymentResume(); resume != nil {
|
||||
resumeToken, err = resume.CreateToken(ResumeTokenClaims{
|
||||
OrderID: order.ID,
|
||||
UserID: order.UserID,
|
||||
ProviderInstanceID: sel.InstanceID,
|
||||
ProviderKey: sel.ProviderKey,
|
||||
PaymentType: req.PaymentType,
|
||||
CanonicalReturnURL: canonicalReturnURL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create payment resume token: %w", err)
|
||||
}
|
||||
}
|
||||
providerReturnURL, err := buildPaymentReturnURL(canonicalReturnURL, order.ID, resumeToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pr, err := prov.CreatePayment(ctx, payment.CreatePaymentRequest{
|
||||
OrderID: outTradeNo,
|
||||
Amount: payAmountStr,
|
||||
PaymentType: req.PaymentType,
|
||||
Subject: subject,
|
||||
ReturnURL: providerReturnURL,
|
||||
ClientIP: req.ClientIP,
|
||||
IsMobile: req.IsMobile,
|
||||
InstanceSubMethods: sel.SupportedTypes,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("[PaymentService] CreatePayment failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err)
|
||||
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment gateway error: %s", err.Error()))
|
||||
@@ -227,8 +261,22 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
|
||||
"payAmount": order.PayAmount,
|
||||
"paymentType": req.PaymentType,
|
||||
"orderType": req.OrderType,
|
||||
"paymentSource": NormalizePaymentSource(req.PaymentSource),
|
||||
})
|
||||
return &CreateOrderResponse{OrderID: order.ID, Amount: order.Amount, PayAmount: payAmount, FeeRate: order.FeeRate, Status: OrderStatusPending, PaymentType: req.PaymentType, PayURL: pr.PayURL, QRCode: pr.QRCode, ClientSecret: pr.ClientSecret, ExpiresAt: order.ExpiresAt, PaymentMode: sel.PaymentMode}, nil
|
||||
return &CreateOrderResponse{
|
||||
OrderID: order.ID,
|
||||
Amount: order.Amount,
|
||||
PayAmount: payAmount,
|
||||
FeeRate: order.FeeRate,
|
||||
Status: OrderStatusPending,
|
||||
PaymentType: req.PaymentType,
|
||||
PayURL: pr.PayURL,
|
||||
QRCode: pr.QRCode,
|
||||
ClientSecret: pr.ClientSecret,
|
||||
ExpiresAt: order.ExpiresAt,
|
||||
PaymentMode: sel.PaymentMode,
|
||||
ResumeToken: resumeToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, limitAmount float64, cfg *PaymentConfig) string {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -200,6 +201,30 @@ func CanonicalizeReturnURL(raw string) (string, error) {
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func buildPaymentReturnURL(base string, orderID int64, resumeToken string) (string, error) {
|
||||
canonical, err := CanonicalizeReturnURL(base)
|
||||
if err != nil || canonical == "" {
|
||||
return canonical, err
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(canonical)
|
||||
if err != nil {
|
||||
return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must be a valid URL")
|
||||
}
|
||||
|
||||
query := parsed.Query()
|
||||
if orderID > 0 {
|
||||
query.Set("order_id", strconv.FormatInt(orderID, 10))
|
||||
}
|
||||
if strings.TrimSpace(resumeToken) != "" {
|
||||
query.Set("resume_token", strings.TrimSpace(resumeToken))
|
||||
}
|
||||
query.Set("status", "success")
|
||||
parsed.RawQuery = query.Encode()
|
||||
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func (s *PaymentResumeService) CreateToken(claims ResumeTokenClaims) (string, error) {
|
||||
if claims.OrderID <= 0 {
|
||||
return "", fmt.Errorf("resume token requires order id")
|
||||
|
||||
@@ -4,6 +4,8 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
@@ -74,6 +76,48 @@ func TestCanonicalizeReturnURLRejectsRelativeURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPaymentReturnURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := buildPaymentReturnURL("https://example.com/payment/result?from=checkout#fragment", 42, "resume-token")
|
||||
if err != nil {
|
||||
t.Fatalf("buildPaymentReturnURL returned error: %v", err)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatalf("url.Parse returned error: %v", err)
|
||||
}
|
||||
if parsed.Fragment != "" {
|
||||
t.Fatalf("buildPaymentReturnURL should strip fragments, got %q", parsed.Fragment)
|
||||
}
|
||||
query := parsed.Query()
|
||||
if query.Get("from") != "checkout" {
|
||||
t.Fatalf("expected original query to be preserved, got %q", query.Get("from"))
|
||||
}
|
||||
if query.Get("order_id") != strconv.FormatInt(42, 10) {
|
||||
t.Fatalf("order_id = %q", query.Get("order_id"))
|
||||
}
|
||||
if query.Get("resume_token") != "resume-token" {
|
||||
t.Fatalf("resume_token = %q", query.Get("resume_token"))
|
||||
}
|
||||
if query.Get("status") != "success" {
|
||||
t.Fatalf("status = %q", query.Get("status"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPaymentReturnURLEmptyBase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := buildPaymentReturnURL("", 42, "resume-token")
|
||||
if err != nil {
|
||||
t.Fatalf("buildPaymentReturnURL returned error: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("buildPaymentReturnURL = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentResumeTokenRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user