feat: wire payment return url payloads
This commit is contained in:
@@ -204,6 +204,8 @@ func (h *PaymentHandler) GetLimits(c *gin.Context) {
|
|||||||
type CreateOrderRequest struct {
|
type CreateOrderRequest struct {
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
PaymentType string `json:"payment_type" binding:"required"`
|
PaymentType string `json:"payment_type" binding:"required"`
|
||||||
|
ReturnURL string `json:"return_url"`
|
||||||
|
PaymentSource string `json:"payment_source"`
|
||||||
OrderType string `json:"order_type"`
|
OrderType string `json:"order_type"`
|
||||||
PlanID int64 `json:"plan_id"`
|
PlanID int64 `json:"plan_id"`
|
||||||
}
|
}
|
||||||
@@ -229,7 +231,8 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
|
|||||||
ClientIP: c.ClientIP(),
|
ClientIP: c.ClientIP(),
|
||||||
IsMobile: isMobile(c),
|
IsMobile: isMobile(c),
|
||||||
SrcHost: c.Request.Host,
|
SrcHost: c.Request.Host,
|
||||||
SrcURL: c.Request.Referer(),
|
ReturnURL: req.ReturnURL,
|
||||||
|
PaymentSource: req.PaymentSource,
|
||||||
OrderType: req.OrderType,
|
OrderType: req.OrderType,
|
||||||
PlanID: req.PlanID,
|
PlanID: req.PlanID,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
|
|||||||
if req.OrderType == "" {
|
if req.OrderType == "" {
|
||||||
req.OrderType = payment.OrderTypeBalance
|
req.OrderType = payment.OrderTypeBalance
|
||||||
}
|
}
|
||||||
|
if normalized := NormalizeVisibleMethod(req.PaymentType); normalized != "" {
|
||||||
|
req.PaymentType = normalized
|
||||||
|
}
|
||||||
cfg, err := s.configService.GetPaymentConfig(ctx)
|
cfg, err := s.configService.GetPaymentConfig(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get payment config: %w", err)
|
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)
|
subject := s.buildPaymentSubject(plan, limitAmount, cfg)
|
||||||
outTradeNo := order.OutTradeNo
|
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 {
|
if err != nil {
|
||||||
slog.Error("[PaymentService] CreatePayment failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err)
|
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()))
|
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,
|
"payAmount": order.PayAmount,
|
||||||
"paymentType": req.PaymentType,
|
"paymentType": req.PaymentType,
|
||||||
"orderType": req.OrderType,
|
"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 {
|
func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, limitAmount float64, cfg *PaymentConfig) string {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -200,6 +201,30 @@ func CanonicalizeReturnURL(raw string) (string, error) {
|
|||||||
return parsed.String(), nil
|
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) {
|
func (s *PaymentResumeService) CreateToken(claims ResumeTokenClaims) (string, error) {
|
||||||
if claims.OrderID <= 0 {
|
if claims.OrderID <= 0 {
|
||||||
return "", fmt.Errorf("resume token requires order id")
|
return "", fmt.Errorf("resume token requires order id")
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
"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) {
|
func TestPaymentResumeTokenRoundTrip(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { CreateOrderResult, MethodLimit } from '@/types/payment'
|
import type { CreateOrderResult, MethodLimit } from '@/types/payment'
|
||||||
import {
|
import {
|
||||||
|
buildCreateOrderPayload,
|
||||||
decidePaymentLaunch,
|
decidePaymentLaunch,
|
||||||
getVisibleMethods,
|
getVisibleMethods,
|
||||||
readPaymentRecoverySnapshot,
|
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', () => {
|
describe('readPaymentRecoverySnapshot', () => {
|
||||||
it('restores an unexpired snapshot when the resume token matches', () => {
|
it('restores an unexpired snapshot when the resume token matches', () => {
|
||||||
const snapshot: PaymentRecoverySnapshot = {
|
const snapshot: PaymentRecoverySnapshot = {
|
||||||
|
|||||||
@@ -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'
|
export const PAYMENT_RECOVERY_STORAGE_KEY = 'payment.recovery.current'
|
||||||
|
|
||||||
@@ -49,6 +49,15 @@ export interface PaymentLaunchDecision {
|
|||||||
stripeMethod?: StripeVisibleMethod
|
stripeMethod?: StripeVisibleMethod
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BuildCreateOrderPayloadInput {
|
||||||
|
amount: number
|
||||||
|
paymentType: string
|
||||||
|
orderType: OrderType
|
||||||
|
planId?: number
|
||||||
|
origin?: string
|
||||||
|
isWechatBrowser: boolean
|
||||||
|
}
|
||||||
|
|
||||||
type CreateOrderFlowResult = CreateOrderResult & {
|
type CreateOrderFlowResult = CreateOrderResult & {
|
||||||
resume_token?: string
|
resume_token?: string
|
||||||
}
|
}
|
||||||
@@ -77,6 +86,28 @@ export function getVisibleMethods(methods: Record<string, MethodLimit>): Record<
|
|||||||
return visible
|
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(
|
export function decidePaymentLaunch(
|
||||||
result: CreateOrderFlowResult,
|
result: CreateOrderFlowResult,
|
||||||
context: PaymentLaunchContext,
|
context: PaymentLaunchContext,
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ export interface CreateOrderRequest {
|
|||||||
payment_type: string
|
payment_type: string
|
||||||
order_type: string
|
order_type: string
|
||||||
plan_id?: number
|
plan_id?: number
|
||||||
|
return_url?: string
|
||||||
|
payment_source?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateOrderResult {
|
export interface CreateOrderResult {
|
||||||
@@ -166,6 +168,7 @@ export interface CreateOrderResult {
|
|||||||
fee_rate: number
|
fee_rate: number
|
||||||
expires_at: string
|
expires_at: string
|
||||||
payment_mode?: string
|
payment_mode?: string
|
||||||
|
resume_token?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardStats {
|
export interface DashboardStats {
|
||||||
|
|||||||
@@ -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 { METHOD_ORDER, POPUP_WINDOW_FEATURES, STRIPE_POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
||||||
import {
|
import {
|
||||||
PAYMENT_RECOVERY_STORAGE_KEY,
|
PAYMENT_RECOVERY_STORAGE_KEY,
|
||||||
|
buildCreateOrderPayload,
|
||||||
clearPaymentRecoverySnapshot,
|
clearPaymentRecoverySnapshot,
|
||||||
decidePaymentLaunch,
|
decidePaymentLaunch,
|
||||||
getVisibleMethods,
|
getVisibleMethods,
|
||||||
@@ -563,12 +564,14 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
|||||||
submitting.value = true
|
submitting.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
const result = await paymentStore.createOrder({
|
const result = await paymentStore.createOrder(buildCreateOrderPayload({
|
||||||
amount: orderAmount,
|
amount: orderAmount,
|
||||||
payment_type: selectedMethod.value,
|
paymentType: selectedMethod.value,
|
||||||
order_type: orderType,
|
orderType,
|
||||||
plan_id: planId,
|
planId,
|
||||||
}) as CreateOrderResult & { resume_token?: string }
|
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 openWindow = (url: string, features = POPUP_WINDOW_FEATURES) => {
|
||||||
const win = window.open(url, 'paymentPopup', features)
|
const win = window.open(url, 'paymentPopup', features)
|
||||||
if (!win || win.closed) {
|
if (!win || win.closed) {
|
||||||
|
|||||||
Reference in New Issue
Block a user