diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 1ddb8ae2..d5c3d7c8 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -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) diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index 128416e4..fa256be7 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -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 { diff --git a/backend/internal/service/payment_resume_service.go b/backend/internal/service/payment_resume_service.go index 894a8198..4f63645e 100644 --- a/backend/internal/service/payment_resume_service.go +++ b/backend/internal/service/payment_resume_service.go @@ -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") diff --git a/backend/internal/service/payment_resume_service_test.go b/backend/internal/service/payment_resume_service_test.go index e56b4a88..9c35ac3d 100644 --- a/backend/internal/service/payment_resume_service_test.go +++ b/backend/internal/service/payment_resume_service_test.go @@ -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() diff --git a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts index f5212f15..fa1a0931 100644 --- a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts +++ b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { CreateOrderResult, MethodLimit } from '@/types/payment' import { + buildCreateOrderPayload, decidePaymentLaunch, getVisibleMethods, readPaymentRecoverySnapshot, @@ -106,6 +107,42 @@ describe('decidePaymentLaunch', () => { }) }) +describe('buildCreateOrderPayload', () => { + it('normalizes visible method aliases and attaches a canonical result URL', () => { + expect(buildCreateOrderPayload({ + amount: 88, + paymentType: 'alipay_direct', + orderType: 'balance', + origin: 'https://app.example.com/', + isWechatBrowser: false, + })).toEqual({ + amount: 88, + payment_type: 'alipay', + order_type: 'balance', + return_url: 'https://app.example.com/payment/result', + payment_source: 'hosted_redirect', + }) + }) + + it('uses WeChat in-app resume source for visible WeChat payments in the WeChat browser', () => { + expect(buildCreateOrderPayload({ + amount: 128, + paymentType: 'wxpay', + orderType: 'subscription', + planId: 7, + origin: 'https://app.example.com', + isWechatBrowser: true, + })).toEqual({ + amount: 128, + payment_type: 'wxpay', + order_type: 'subscription', + plan_id: 7, + return_url: 'https://app.example.com/payment/result', + payment_source: 'wechat_in_app_resume', + }) + }) +}) + describe('readPaymentRecoverySnapshot', () => { it('restores an unexpired snapshot when the resume token matches', () => { const snapshot: PaymentRecoverySnapshot = { diff --git a/frontend/src/components/payment/paymentFlow.ts b/frontend/src/components/payment/paymentFlow.ts index 70225a0c..ac5a27a0 100644 --- a/frontend/src/components/payment/paymentFlow.ts +++ b/frontend/src/components/payment/paymentFlow.ts @@ -1,4 +1,4 @@ -import type { CreateOrderResult, MethodLimit, OrderType } from '@/types/payment' +import type { CreateOrderRequest, CreateOrderResult, MethodLimit, OrderType } from '@/types/payment' export const PAYMENT_RECOVERY_STORAGE_KEY = 'payment.recovery.current' @@ -49,6 +49,15 @@ export interface PaymentLaunchDecision { stripeMethod?: StripeVisibleMethod } +export interface BuildCreateOrderPayloadInput { + amount: number + paymentType: string + orderType: OrderType + planId?: number + origin?: string + isWechatBrowser: boolean +} + type CreateOrderFlowResult = CreateOrderResult & { resume_token?: string } @@ -77,6 +86,28 @@ export function getVisibleMethods(methods: Record): Record< return visible } +export function buildCreateOrderPayload(input: BuildCreateOrderPayloadInput): CreateOrderRequest { + const visibleMethod = normalizeVisibleMethod(input.paymentType) || input.paymentType.trim() + const normalizedOrigin = (input.origin || '').trim().replace(/\/+$/, '') + const payload: CreateOrderRequest = { + amount: input.amount, + payment_type: visibleMethod, + order_type: input.orderType, + payment_source: visibleMethod === 'wxpay' && input.isWechatBrowser + ? 'wechat_in_app_resume' + : 'hosted_redirect', + } + + if (input.planId) { + payload.plan_id = input.planId + } + if (normalizedOrigin) { + payload.return_url = `${normalizedOrigin}/payment/result` + } + + return payload +} + export function decidePaymentLaunch( result: CreateOrderFlowResult, context: PaymentLaunchContext, diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index 7ecbb9a9..fe0f794a 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -154,6 +154,8 @@ export interface CreateOrderRequest { payment_type: string order_type: string plan_id?: number + return_url?: string + payment_source?: string } export interface CreateOrderResult { @@ -166,6 +168,7 @@ export interface CreateOrderResult { fee_rate: number expires_at: string payment_mode?: string + resume_token?: string } export interface DashboardStats { diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 838f3000..f973ad5b 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -267,6 +267,7 @@ import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vu import { METHOD_ORDER, POPUP_WINDOW_FEATURES, STRIPE_POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig' import { PAYMENT_RECOVERY_STORAGE_KEY, + buildCreateOrderPayload, clearPaymentRecoverySnapshot, decidePaymentLaunch, getVisibleMethods, @@ -563,12 +564,14 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n submitting.value = true errorMessage.value = '' try { - const result = await paymentStore.createOrder({ + const result = await paymentStore.createOrder(buildCreateOrderPayload({ amount: orderAmount, - payment_type: selectedMethod.value, - order_type: orderType, - plan_id: planId, - }) as CreateOrderResult & { resume_token?: string } + paymentType: selectedMethod.value, + orderType, + planId, + origin: typeof window !== 'undefined' ? window.location.origin : '', + isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent), + })) as CreateOrderResult & { resume_token?: string } const openWindow = (url: string, features = POPUP_WINDOW_FEATURES) => { const win = window.open(url, 'paymentPopup', features) if (!win || win.closed) {