Commit b51bc7ee authored by IanShaw027's avatar IanShaw027
Browse files

feat: wire payment return url payloads

parent 7826e988
...@@ -204,6 +204,8 @@ func (h *PaymentHandler) GetLimits(c *gin.Context) { ...@@ -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) { ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 ( ...@@ -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) { ...@@ -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 ...@@ -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) { ...@@ -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()
......
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', () => { ...@@ -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 = {
......
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 { ...@@ -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< ...@@ -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 { ...@@ -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 { ...@@ -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 ...@@ -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 ...@@ -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) {
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment