mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-22 09:43:46 +00:00
feat(payment): configurable payment mode (qrcode/redirect/popup)
- EasyPay supports 3 modes: qrcode (API/QR), redirect (new tab), popup (small window) - Stripe supports 2 modes: popup (small window, default), redirect (new tab) - Backend passes payment_mode through to create-order response - Frontend opens pay URL based on configured mode
This commit is contained in:
@@ -90,6 +90,7 @@ func (lb *DefaultLoadBalancer) SelectInstance(ctx context.Context, providerKey s
|
||||
InstanceID: fmt.Sprintf("%d", selected.ID),
|
||||
Config: config,
|
||||
SupportedTypes: selected.SupportedTypes,
|
||||
PaymentMode: selected.PaymentMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -57,8 +57,9 @@ func (e *EasyPay) SupportedTypes() []payment.PaymentType {
|
||||
|
||||
func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
// Payment mode determined by instance config, not payment type.
|
||||
// "redirect" → hosted page (submit.php); default → API call (mapi.php).
|
||||
if e.config["paymentMode"] == "redirect" {
|
||||
// "redirect"/"popup" → hosted page (submit.php); "qrcode"/default → API call (mapi.php).
|
||||
mode := e.config["paymentMode"]
|
||||
if mode == "redirect" || mode == "popup" {
|
||||
return e.createRedirectPayment(req)
|
||||
}
|
||||
return e.createAPIPayment(ctx, req)
|
||||
|
||||
@@ -126,6 +126,7 @@ type InstanceSelection struct {
|
||||
InstanceID string
|
||||
Config map[string]string
|
||||
SupportedTypes string // Comma-separated list of supported payment types from the instance
|
||||
PaymentMode string // Payment display mode: "qrcode", "redirect", "popup"
|
||||
}
|
||||
|
||||
// Provider defines the interface that all payment providers must implement.
|
||||
|
||||
@@ -275,7 +275,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
|
||||
return nil, fmt.Errorf("update order with payment details: %w", err)
|
||||
}
|
||||
s.writeAuditLog(ctx, order.ID, "ORDER_CREATED", fmt.Sprintf("user:%d", req.UserID), map[string]any{"amount": req.Amount, "paymentType": req.PaymentType, "orderType": req.OrderType})
|
||||
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}, 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}, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, payAmountStr string, cfg *PaymentConfig) string {
|
||||
|
||||
@@ -81,6 +81,7 @@ type CreateOrderResponse struct {
|
||||
QRCode string `json:"qr_code,omitempty"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
PaymentMode string `json:"payment_mode,omitempty"`
|
||||
}
|
||||
|
||||
type OrderListParams struct {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
<!-- Payment mode + Supported types (same row) -->
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div v-if="form.provider_key === 'easypay'">
|
||||
<div v-if="form.provider_key === 'easypay' || form.provider_key === 'stripe'">
|
||||
<label class="input-label">
|
||||
{{ t('admin.settings.payment.paymentMode') }}
|
||||
<span class="text-red-500">*</span>
|
||||
@@ -229,7 +229,9 @@ import {
|
||||
PROVIDER_CALLBACK_PATHS,
|
||||
WEBHOOK_PATHS,
|
||||
PAYMENT_MODE_REDIRECT,
|
||||
PAYMENT_MODE_API,
|
||||
PAYMENT_MODE_QRCODE,
|
||||
PAYMENT_MODE_POPUP,
|
||||
STRIPE_PAYMENT_MODES,
|
||||
getAvailableTypes,
|
||||
extractBaseUrl,
|
||||
} from './providerConfig'
|
||||
@@ -266,7 +268,7 @@ const form = reactive({
|
||||
provider_key: 'easypay',
|
||||
supported_types: [] as string[],
|
||||
enabled: true,
|
||||
payment_mode: PAYMENT_MODE_API,
|
||||
payment_mode: PAYMENT_MODE_QRCODE,
|
||||
refund_enabled: false,
|
||||
})
|
||||
const config = reactive<Record<string, string>>({})
|
||||
@@ -285,10 +287,19 @@ const stripeWebhookUrl = computed(() =>
|
||||
|
||||
const callbackPaths = computed(() => PROVIDER_CALLBACK_PATHS[form.provider_key] || null)
|
||||
|
||||
const paymentModeOptions = computed(() => [
|
||||
{ value: PAYMENT_MODE_REDIRECT, label: t('admin.settings.payment.modeRedirect') },
|
||||
{ value: PAYMENT_MODE_API, label: t('admin.settings.payment.modeQRCode') },
|
||||
])
|
||||
const paymentModeOptions = computed(() => {
|
||||
if (form.provider_key === 'stripe') {
|
||||
return STRIPE_PAYMENT_MODES.map((m) => ({
|
||||
value: m,
|
||||
label: m === PAYMENT_MODE_POPUP ? t('admin.settings.payment.modePopup') : t('admin.settings.payment.modeRedirect'),
|
||||
}))
|
||||
}
|
||||
return [
|
||||
{ value: PAYMENT_MODE_QRCODE, label: t('admin.settings.payment.modeQRCode') },
|
||||
{ value: PAYMENT_MODE_REDIRECT, label: t('admin.settings.payment.modeRedirect') },
|
||||
{ value: PAYMENT_MODE_POPUP, label: t('admin.settings.payment.modePopup') },
|
||||
]
|
||||
})
|
||||
|
||||
const availableTypes = computed(() => {
|
||||
const base = getAvailableTypes(form.provider_key, props.allPaymentTypes, props.redirectLabel)
|
||||
@@ -438,7 +449,7 @@ function handleSave() {
|
||||
name: form.name,
|
||||
supported_types: form.supported_types,
|
||||
enabled: form.enabled,
|
||||
payment_mode: form.provider_key === 'easypay' ? form.payment_mode : '',
|
||||
payment_mode: (form.provider_key === 'easypay' || form.provider_key === 'stripe') ? form.payment_mode : '',
|
||||
refund_enabled: form.refund_enabled,
|
||||
config: filteredConfig,
|
||||
limits: serializeLimits(),
|
||||
@@ -457,7 +468,7 @@ function reset(defaultKey: string) {
|
||||
form.provider_key = defaultKey
|
||||
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[defaultKey] || [])]
|
||||
form.enabled = true
|
||||
form.payment_mode = defaultKey === 'easypay' ? PAYMENT_MODE_API : ''
|
||||
form.payment_mode = defaultKey === 'easypay' ? PAYMENT_MODE_QRCODE : defaultKey === 'stripe' ? PAYMENT_MODE_POPUP : ''
|
||||
form.refund_enabled = false
|
||||
clearConfig()
|
||||
applyDefaults()
|
||||
@@ -468,7 +479,7 @@ function loadProvider(provider: ProviderInstance) {
|
||||
form.provider_key = provider.provider_key
|
||||
form.supported_types = provider.supported_types
|
||||
form.enabled = provider.enabled
|
||||
form.payment_mode = provider.payment_mode || PAYMENT_MODE_API
|
||||
form.payment_mode = provider.payment_mode || (provider.provider_key === 'stripe' ? PAYMENT_MODE_POPUP : PAYMENT_MODE_QRCODE)
|
||||
form.refund_enabled = provider.refund_enabled
|
||||
clearConfig()
|
||||
// Pre-fill config from API response (non-sensitive in cleartext, sensitive masked as ••••••••)
|
||||
|
||||
@@ -68,7 +68,7 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import ToggleSwitch from './ToggleSwitch.vue'
|
||||
import type { ProviderInstance } from '@/types/payment'
|
||||
import type { TypeOption } from './providerConfig'
|
||||
import { PAYMENT_MODE_REDIRECT, PAYMENT_MODE_API } from './providerConfig'
|
||||
import { PAYMENT_MODE_REDIRECT, PAYMENT_MODE_QRCODE, PAYMENT_MODE_POPUP } from './providerConfig'
|
||||
|
||||
const PROVIDER_KEY_LABELS: Record<string, string> = {
|
||||
easypay: 'admin.settings.payment.providerEasypay',
|
||||
@@ -96,7 +96,8 @@ const keyLabel = computed(() => t(PROVIDER_KEY_LABELS[props.provider.provider_ke
|
||||
|
||||
const modeLabel = computed(() => {
|
||||
if (props.provider.payment_mode === PAYMENT_MODE_REDIRECT) return t('admin.settings.payment.modeRedirect')
|
||||
if (props.provider.payment_mode === PAYMENT_MODE_API) return t('admin.settings.payment.modeQRCode')
|
||||
if (props.provider.payment_mode === PAYMENT_MODE_QRCODE) return t('admin.settings.payment.modeQRCode')
|
||||
if (props.provider.payment_mode === PAYMENT_MODE_POPUP) return t('admin.settings.payment.modePopup')
|
||||
return ''
|
||||
})
|
||||
|
||||
|
||||
@@ -34,14 +34,18 @@ export const PROVIDER_SUPPORTED_TYPES: Record<string, string[]> = {
|
||||
}
|
||||
|
||||
/** Available payment modes for EasyPay providers. */
|
||||
export const EASYPAY_PAYMENT_MODES = ['redirect', 'api'] as const
|
||||
export const EASYPAY_PAYMENT_MODES = ['qrcode', 'redirect', 'popup'] as const
|
||||
|
||||
/** Available payment modes for Stripe providers. */
|
||||
export const STRIPE_PAYMENT_MODES = ['popup', 'redirect'] as const
|
||||
|
||||
/** Fixed display order for user-facing payment methods */
|
||||
export const METHOD_ORDER = ['alipay', 'wxpay', 'stripe'] as const
|
||||
|
||||
/** Payment mode constants */
|
||||
export const PAYMENT_MODE_QRCODE = 'qrcode'
|
||||
export const PAYMENT_MODE_REDIRECT = 'redirect'
|
||||
export const PAYMENT_MODE_API = 'api'
|
||||
export const PAYMENT_MODE_POPUP = 'popup'
|
||||
|
||||
/** Window features for payment popup windows */
|
||||
export const POPUP_WINDOW_FEATURES = 'width=520,height=700,left=200,top=100,scrollbars=yes,resizable=yes'
|
||||
|
||||
@@ -4377,6 +4377,7 @@ export default {
|
||||
paymentMode: 'Payment Mode',
|
||||
modeRedirect: 'Redirect',
|
||||
modeQRCode: 'QR Code',
|
||||
modePopup: 'Popup',
|
||||
validationNameRequired: 'Provider name is required',
|
||||
validationTypesRequired: 'Please select at least one supported payment type',
|
||||
validationFieldRequired: '{field} is required',
|
||||
|
||||
@@ -4550,6 +4550,7 @@ export default {
|
||||
paymentMode: '支付模式',
|
||||
modeRedirect: '跳转',
|
||||
modeQRCode: '二维码',
|
||||
modePopup: '弹窗',
|
||||
validationNameRequired: '服务商名称不能为空',
|
||||
validationTypesRequired: '请至少选择一种支持的支付方式',
|
||||
validationFieldRequired: '{field} 不能为空',
|
||||
|
||||
@@ -147,6 +147,7 @@ export interface CreateOrderResult {
|
||||
client_secret?: string
|
||||
pay_amount: number
|
||||
expires_at: string
|
||||
payment_mode?: string
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
|
||||
@@ -310,13 +310,21 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
|
||||
order_type: orderType,
|
||||
plan_id: planId,
|
||||
})
|
||||
const mode = result.payment_mode || 'popup'
|
||||
const openWindow = (url: string) => {
|
||||
if (mode === 'redirect') {
|
||||
window.open(url, '_blank')
|
||||
} else {
|
||||
window.open(url, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
||||
}
|
||||
}
|
||||
if (result.client_secret) {
|
||||
// Stripe: open in popup window, show waiting state inline
|
||||
// Stripe: open payment page, show waiting state inline
|
||||
const stripeUrl = router.resolve({
|
||||
path: '/payment/stripe',
|
||||
query: { order_id: String(result.order_id), client_secret: result.client_secret },
|
||||
}).href
|
||||
window.open(stripeUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
||||
openWindow(stripeUrl)
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, qrCode: '', expiresAt: '',
|
||||
paymentType: selectedMethod.value, payUrl: stripeUrl,
|
||||
@@ -330,8 +338,8 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
|
||||
}
|
||||
paymentPhase.value = 'paying'
|
||||
} else if (result.pay_url) {
|
||||
// Redirect mode: open in popup, show waiting state inline
|
||||
window.open(result.pay_url, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
||||
// Redirect/popup mode: open payment URL, show waiting state inline
|
||||
openWindow(result.pay_url)
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
|
||||
paymentType: selectedMethod.value, payUrl: result.pay_url,
|
||||
|
||||
Reference in New Issue
Block a user