fix(payment): audit fixes for alipay/wxpay/stripe payment providers

Backend:
- Extract YuanToFen/FenToYuan to payment/amount.go using shopspring/decimal
- Require alipay publicKey in config validation
- Fix wxpay webhook response to return JSON per V3 spec
- Remove wxpay certSerial fallback to publicKeyId
- Define magic strings as named constants in wxpay/alipay providers
- Add slog warning for wxpay H5→Native payment downgrade
- Make EncryptionKey validation return error on invalid (non-empty) key
- Make decryptConfig propagate errors instead of returning nil
- Add idempotency check in doBalance to prevent stuck FAILED retries

Frontend:
- Fix dashboard currency symbol from $ to ¥
- Fix AdminPaymentPlansView any type to proper SubscriptionPlan type
- Make quick amount buttons follow selected payment method limits
- Center help image with larger height and text below
This commit is contained in:
erio
2026-04-08 17:11:32 +08:00
parent 1c63ea1448
commit 5bae3b0577
6 changed files with 388 additions and 819 deletions

View File

@@ -50,7 +50,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
refreshTokenCache := repository.NewRefreshTokenCache(redisClient) refreshTokenCache := repository.NewRefreshTokenCache(redisClient)
settingRepository := repository.NewSettingRepository(client) settingRepository := repository.NewSettingRepository(client)
groupRepository := repository.NewGroupRepository(client, db) groupRepository := repository.NewGroupRepository(client, db)
channelRepository := repository.NewChannelRepository(db)
settingService := service.ProvideSettingService(settingRepository, groupRepository, configConfig) settingService := service.ProvideSettingService(settingRepository, groupRepository, configConfig)
emailCache := repository.NewEmailCache(redisClient) emailCache := repository.NewEmailCache(redisClient)
emailService := service.NewEmailService(settingRepository, emailCache) emailService := service.NewEmailService(settingRepository, emailCache)
@@ -65,7 +64,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
userGroupRateRepository := repository.NewUserGroupRateRepository(db) userGroupRateRepository := repository.NewUserGroupRateRepository(db)
apiKeyCache := repository.NewAPIKeyCache(redisClient) apiKeyCache := repository.NewAPIKeyCache(redisClient)
apiKeyService := service.NewAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, userGroupRateRepository, apiKeyCache, configConfig) apiKeyService := service.NewAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, userGroupRateRepository, apiKeyCache, configConfig)
apiKeyService.SetRateLimitCacheInvalidator(billingCache)
apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService) apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService)
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator) promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig) subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig)
@@ -73,15 +71,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
userService := service.NewUserService(userRepository, apiKeyAuthCacheInvalidator, billingCache) userService := service.NewUserService(userRepository, apiKeyAuthCacheInvalidator, billingCache)
redeemCache := repository.NewRedeemCache(redisClient) redeemCache := repository.NewRedeemCache(redisClient)
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator) redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator)
registry := payment.ProvideRegistry()
encryptionKey, err := payment.ProvideEncryptionKey(configConfig)
if err != nil {
return nil, err
}
defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey)
paymentConfigService := service.ProvidePaymentConfigService(client, settingRepository, encryptionKey)
paymentService := service.NewPaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository)
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
secretEncryptor, err := repository.NewAESEncryptor(configConfig) secretEncryptor, err := repository.NewAESEncryptor(configConfig)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -92,7 +81,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
userHandler := handler.NewUserHandler(userService) userHandler := handler.NewUserHandler(userService)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageLogRepository := repository.NewUsageLogRepository(client, db) usageLogRepository := repository.NewUsageLogRepository(client, db)
usageBillingRepository := repository.NewUsageBillingRepository(client, db)
usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator) usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
redeemHandler := handler.NewRedeemHandler(redeemService) redeemHandler := handler.NewRedeemHandler(redeemService)
@@ -110,7 +98,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
} }
dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, configConfig) dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, configConfig)
dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService) dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService)
schedulerCache := repository.ProvideSchedulerCache(redisClient, configConfig) schedulerCache := repository.NewSchedulerCache(redisClient)
accountRepository := repository.NewAccountRepository(client, db, schedulerCache) accountRepository := repository.NewAccountRepository(client, db, schedulerCache)
proxyRepository := repository.NewProxyRepository(client, db) proxyRepository := repository.NewProxyRepository(client, db)
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
@@ -120,11 +108,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig) concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig) concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService) adminUserHandler := admin.NewUserHandler(adminService, concurrencyService)
sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig)
rpmCache := repository.NewRPMCache(redisClient)
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
claudeOAuthClient := repository.NewClaudeOAuthClient() claudeOAuthClient := repository.NewClaudeOAuthClient()
oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient) oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient)
openAIOAuthClient := repository.NewOpenAIOAuthClient() openAIOAuthClient := repository.NewOpenAIOAuthClient()
openAIOAuthService := service.NewOpenAIOAuthService(proxyRepository, openAIOAuthClient) openAIOAuthService := service.NewOpenAIOAuthService(proxyRepository, openAIOAuthClient)
openAIOAuthService.SetPrivacyClientFactory(privacyClientFactory)
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig) geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient() geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient()
driveClient := repository.NewGeminiDriveClient() driveClient := repository.NewGeminiDriveClient()
@@ -134,7 +125,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
tempUnschedCache := repository.NewTempUnschedCache(redisClient) tempUnschedCache := repository.NewTempUnschedCache(redisClient)
timeoutCounterCache := repository.NewTimeoutCounterCache(redisClient) timeoutCounterCache := repository.NewTimeoutCounterCache(redisClient)
geminiTokenCache := repository.NewGeminiTokenCache(redisClient) geminiTokenCache := repository.NewGeminiTokenCache(redisClient)
oauthRefreshAPI := service.NewOAuthRefreshAPI(accountRepository, geminiTokenCache)
compositeTokenCacheInvalidator := service.NewCompositeTokenCacheInvalidator(geminiTokenCache) compositeTokenCacheInvalidator := service.NewCompositeTokenCacheInvalidator(geminiTokenCache)
rateLimitService := service.ProvideRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache, timeoutCounterCache, settingService, compositeTokenCacheInvalidator) rateLimitService := service.ProvideRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache, timeoutCounterCache, settingService, compositeTokenCacheInvalidator)
httpUpstream := repository.NewHTTPUpstream(configConfig) httpUpstream := repository.NewHTTPUpstream(configConfig)
@@ -142,23 +132,20 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository) antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
usageCache := service.NewUsageCache() usageCache := service.NewUsageCache()
identityCache := repository.NewIdentityCache(redisClient) identityCache := repository.NewIdentityCache(redisClient)
geminiTokenProvider := service.ProvideGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService, oauthRefreshAPI)
gatewayCache := repository.NewGatewayCache(redisClient)
schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db)
schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig)
antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oauthRefreshAPI, tempUnschedCache)
internal500CounterCache := repository.NewInternal500CounterCache(redisClient)
tlsFingerprintProfileRepository := repository.NewTLSFingerprintProfileRepository(client) tlsFingerprintProfileRepository := repository.NewTLSFingerprintProfileRepository(client)
tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient) tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient)
tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache) tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache)
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService) accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService)
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache) oAuthRefreshAPI := service.NewOAuthRefreshAPI(accountRepository, geminiTokenCache)
geminiTokenProvider := service.ProvideGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService, oAuthRefreshAPI)
gatewayCache := repository.NewGatewayCache(redisClient)
schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db)
schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig)
antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oAuthRefreshAPI, tempUnschedCache)
internal500CounterCache := repository.NewInternal500CounterCache(redisClient)
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache, accountUsageService)
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService) accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig)
rpmCache := repository.NewRPMCache(redisClient)
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator) accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService) adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
dataManagementService := service.NewDataManagementService() dataManagementService := service.NewDataManagementService()
@@ -175,6 +162,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService) adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService)
promoHandler := admin.NewPromoHandler(promoService) promoHandler := admin.NewPromoHandler(promoService)
opsRepository := repository.NewOpsRepository(db) opsRepository := repository.NewOpsRepository(db)
usageBillingRepository := repository.NewUsageBillingRepository(client, db)
pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig) pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig)
pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient) pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient)
if err != nil { if err != nil {
@@ -183,17 +171,17 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
billingService := service.NewBillingService(configConfig, pricingService) billingService := service.NewBillingService(configConfig, pricingService)
identityService := service.NewIdentityService(identityCache) identityService := service.NewIdentityService(identityCache)
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService) deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oauthRefreshAPI) claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oAuthRefreshAPI)
digestSessionStore := service.NewDigestSessionStore() digestSessionStore := service.NewDigestSessionStore()
channelRepository := repository.NewChannelRepository(db)
channelService := service.NewChannelService(channelRepository, apiKeyAuthCacheInvalidator) channelService := service.NewChannelService(channelRepository, apiKeyAuthCacheInvalidator)
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService) modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver) gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver)
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oauthRefreshAPI) openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI)
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService) openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService)
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig) geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository) opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository)
opsService := service.NewOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink) opsService := service.NewOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink)
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService)
opsHandler := admin.NewOpsHandler(opsService) opsHandler := admin.NewOpsHandler(opsService)
updateCache := repository.NewUpdateCache(redisClient) updateCache := repository.NewUpdateCache(redisClient)
gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig) gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig)
@@ -221,8 +209,18 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository) scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository)
scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService) scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService)
channelHandler := admin.NewChannelHandler(channelService, billingService) channelHandler := admin.NewChannelHandler(channelService, billingService)
adminPaymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService) registry := payment.ProvideRegistry()
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, adminPaymentHandler) encryptionKey, err := payment.ProvideEncryptionKey(configConfig)
if err != nil {
return nil, err
}
defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey)
paymentConfigService := service.ProvidePaymentConfigService(client, settingRepository, encryptionKey)
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService)
paymentService := service.NewPaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository)
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, paymentHandler)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
@@ -245,7 +243,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig) opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig)
opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig) opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig)
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig) opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oauthRefreshAPI) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI)
accountExpiryService := service.ProvideAccountExpiryService(accountRepository) accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository) subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig) scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)

View File

@@ -4,7 +4,6 @@ import (
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"strings" "strings"
"github.com/Wei-Shaw/sub2api/internal/payment" "github.com/Wei-Shaw/sub2api/internal/payment"
@@ -73,13 +72,9 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
rawBody = string(body) rawBody = string(body)
} }
// Extract out_trade_no to look up the order's specific provider instance. provider, err := h.registry.GetProviderByKey(providerKey)
// This is needed when multiple instances of the same provider exist (e.g. multiple EasyPay accounts).
outTradeNo := extractOutTradeNo(rawBody, providerKey)
provider, err := h.paymentService.GetWebhookProvider(c.Request.Context(), providerKey, outTradeNo)
if err != nil { if err != nil {
slog.Warn("[Payment Webhook] provider not found", "provider", providerKey, "outTradeNo", outTradeNo, "error", err) slog.Warn("[Payment Webhook] provider not registered", "provider", providerKey, "error", err)
writeSuccessResponse(c, providerKey) writeSuccessResponse(c, providerKey)
return return
} }
@@ -116,40 +111,19 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
writeSuccessResponse(c, providerKey) writeSuccessResponse(c, providerKey)
} }
// extractOutTradeNo parses the webhook body to find the out_trade_no.
// This allows looking up the correct provider instance before verification.
func extractOutTradeNo(rawBody, providerKey string) string {
switch providerKey {
case payment.TypeEasyPay:
values, err := url.ParseQuery(rawBody)
if err == nil {
return values.Get("out_trade_no")
}
}
// For other providers (Stripe, Alipay direct, WxPay direct), the registry
// typically has only one instance, so no instance lookup is needed.
return ""
}
// wxpaySuccessResponse is the JSON response expected by WeChat Pay webhook. // wxpaySuccessResponse is the JSON response expected by WeChat Pay webhook.
type wxpaySuccessResponse struct { type wxpaySuccessResponse struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"` Message string `json:"message"`
} }
// WeChat Pay webhook success response constants.
const (
wxpaySuccessCode = "SUCCESS"
wxpaySuccessMessage = "成功"
)
// writeSuccessResponse sends the provider-specific success response. // writeSuccessResponse sends the provider-specific success response.
// WeChat Pay requires JSON {"code":"SUCCESS","message":"成功"}; // WeChat Pay requires JSON {"code":"SUCCESS","message":"成功"};
// Stripe expects an empty 200; others accept plain text "success". // Stripe expects an empty 200; others accept plain text "success".
func writeSuccessResponse(c *gin.Context, providerKey string) { func writeSuccessResponse(c *gin.Context, providerKey string) {
switch providerKey { switch providerKey {
case payment.TypeWxpay: case payment.TypeWxpay:
c.JSON(http.StatusOK, wxpaySuccessResponse{Code: wxpaySuccessCode, Message: wxpaySuccessMessage}) c.JSON(http.StatusOK, wxpaySuccessResponse{Code: "SUCCESS", Message: "成功"})
case payment.TypeStripe: case payment.TypeStripe:
c.String(http.StatusOK, "") c.String(http.StatusOK, "")
default: default:

View File

@@ -2,16 +2,13 @@ package service
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
dbent "github.com/Wei-Shaw/sub2api/ent" dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance" "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
"github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
"github.com/Wei-Shaw/sub2api/internal/payment" "github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
) )
const ( const (
@@ -26,6 +23,8 @@ const (
SettingBalancePayDisabled = "BALANCE_PAYMENT_DISABLED" SettingBalancePayDisabled = "BALANCE_PAYMENT_DISABLED"
SettingProductNamePrefix = "PRODUCT_NAME_PREFIX" SettingProductNamePrefix = "PRODUCT_NAME_PREFIX"
SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX" SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX"
SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL"
SettingHelpText = "PAYMENT_HELP_TEXT"
SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED" SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED"
SettingCancelRateLimitMax = "CANCEL_RATE_LIMIT_MAX" SettingCancelRateLimitMax = "CANCEL_RATE_LIMIT_MAX"
SettingCancelWindowSize = "CANCEL_RATE_LIMIT_WINDOW" SettingCancelWindowSize = "CANCEL_RATE_LIMIT_WINDOW"
@@ -33,91 +32,126 @@ const (
SettingCancelWindowMode = "CANCEL_RATE_LIMIT_WINDOW_MODE" SettingCancelWindowMode = "CANCEL_RATE_LIMIT_WINDOW_MODE"
) )
// Default values for payment configuration settings.
const (
defaultOrderTimeoutMin = 30
defaultMaxPendingOrders = 3
)
// PaymentConfig holds the payment system configuration. // PaymentConfig holds the payment system configuration.
type PaymentConfig struct { type PaymentConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
MinAmount float64 `json:"minAmount"` MinAmount float64 `json:"min_amount"`
MaxAmount float64 `json:"maxAmount"` MaxAmount float64 `json:"max_amount"`
DailyLimit float64 `json:"dailyLimit"` DailyLimit float64 `json:"daily_limit"`
OrderTimeoutMin int `json:"orderTimeoutMinutes"` OrderTimeoutMin int `json:"order_timeout_minutes"`
MaxPendingOrders int `json:"maxPendingOrders"` MaxPendingOrders int `json:"max_pending_orders"`
EnabledTypes []string `json:"enabledTypes"` EnabledTypes []string `json:"enabled_payment_types"`
BalanceDisabled bool `json:"balanceDisabled"` BalanceDisabled bool `json:"balance_disabled"`
LoadBalanceStrategy string `json:"loadBalanceStrategy"` LoadBalanceStrategy string `json:"load_balance_strategy"`
ProductNamePrefix string `json:"productNamePrefix"` ProductNamePrefix string `json:"product_name_prefix"`
ProductNameSuffix string `json:"productNameSuffix"` ProductNameSuffix string `json:"product_name_suffix"`
HelpImageURL string `json:"help_image_url"`
HelpText string `json:"help_text"`
StripePublishableKey string `json:"stripe_publishable_key,omitempty"`
// Cancel rate limit settings
CancelRateLimitEnabled bool `json:"cancel_rate_limit_enabled"`
CancelRateLimitMax int `json:"cancel_rate_limit_max"`
CancelRateLimitWindow int `json:"cancel_rate_limit_window"`
CancelRateLimitUnit string `json:"cancel_rate_limit_unit"`
CancelRateLimitMode string `json:"cancel_rate_limit_window_mode"`
} }
// UpdatePaymentConfigRequest contains fields to update payment configuration. // UpdatePaymentConfigRequest contains fields to update payment configuration.
type UpdatePaymentConfigRequest struct { type UpdatePaymentConfigRequest struct {
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
MinAmount *float64 `json:"minAmount"` MinAmount *float64 `json:"min_amount"`
MaxAmount *float64 `json:"maxAmount"` MaxAmount *float64 `json:"max_amount"`
DailyLimit *float64 `json:"dailyLimit"` DailyLimit *float64 `json:"daily_limit"`
OrderTimeoutMin *int `json:"orderTimeoutMinutes"` OrderTimeoutMin *int `json:"order_timeout_minutes"`
MaxPendingOrders *int `json:"maxPendingOrders"` MaxPendingOrders *int `json:"max_pending_orders"`
EnabledTypes []string `json:"enabledTypes"` EnabledTypes []string `json:"enabled_payment_types"`
BalanceDisabled *bool `json:"balanceDisabled"` BalanceDisabled *bool `json:"balance_disabled"`
LoadBalanceStrategy *string `json:"loadBalanceStrategy"` LoadBalanceStrategy *string `json:"load_balance_strategy"`
ProductNamePrefix *string `json:"productNamePrefix"` ProductNamePrefix *string `json:"product_name_prefix"`
ProductNameSuffix *string `json:"productNameSuffix"` ProductNameSuffix *string `json:"product_name_suffix"`
HelpImageURL *string `json:"help_image_url"`
HelpText *string `json:"help_text"`
// Cancel rate limit settings
CancelRateLimitEnabled *bool `json:"cancel_rate_limit_enabled"`
CancelRateLimitMax *int `json:"cancel_rate_limit_max"`
CancelRateLimitWindow *int `json:"cancel_rate_limit_window"`
CancelRateLimitUnit *string `json:"cancel_rate_limit_unit"`
CancelRateLimitMode *string `json:"cancel_rate_limit_window_mode"`
} }
// MethodLimits holds per-payment-type limits. // MethodLimits holds per-payment-type limits.
type MethodLimits struct { type MethodLimits struct {
PaymentType string `json:"paymentType"` PaymentType string `json:"payment_type"`
FeeRate float64 `json:"feeRate"` FeeRate float64 `json:"fee_rate"`
DailyLimit float64 `json:"dailyLimit"` DailyLimit float64 `json:"daily_limit"`
SingleMin float64 `json:"singleMin"` SingleMin float64 `json:"single_min"`
SingleMax float64 `json:"singleMax"` SingleMax float64 `json:"single_max"`
}
// MethodLimitsResponse is the full response for the user-facing /limits API.
// It includes per-method limits and the global widest range (union of all methods).
type MethodLimitsResponse struct {
Methods map[string]MethodLimits `json:"methods"`
GlobalMin float64 `json:"global_min"` // 0 = no minimum
GlobalMax float64 `json:"global_max"` // 0 = no maximum
} }
type CreateProviderInstanceRequest struct { type CreateProviderInstanceRequest struct {
ProviderKey string `json:"providerKey"` ProviderKey string `json:"provider_key"`
Name string `json:"name"` Name string `json:"name"`
Config map[string]string `json:"config"` Config map[string]string `json:"config"`
SupportedTypes string `json:"supportedTypes"` SupportedTypes []string `json:"supported_types"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
SortOrder int `json:"sortOrder"` PaymentMode string `json:"payment_mode"`
SortOrder int `json:"sort_order"`
Limits string `json:"limits"` Limits string `json:"limits"`
RefundEnabled bool `json:"refundEnabled"` RefundEnabled bool `json:"refund_enabled"`
} }
type UpdateProviderInstanceRequest struct { type UpdateProviderInstanceRequest struct {
Name *string `json:"name"` Name *string `json:"name"`
Config map[string]string `json:"config"` Config map[string]string `json:"config"`
SupportedTypes *string `json:"supportedTypes"` SupportedTypes []string `json:"supported_types"`
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
SortOrder *int `json:"sortOrder"` PaymentMode *string `json:"payment_mode"`
SortOrder *int `json:"sort_order"`
Limits *string `json:"limits"` Limits *string `json:"limits"`
RefundEnabled *bool `json:"refundEnabled"` RefundEnabled *bool `json:"refund_enabled"`
} }
type CreatePlanRequest struct { type CreatePlanRequest struct {
GroupID int64 `json:"groupId"` GroupID int64 `json:"group_id"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Price float64 `json:"price"` Price float64 `json:"price"`
OriginalPrice *float64 `json:"originalPrice"` OriginalPrice *float64 `json:"original_price"`
ValidityDays int `json:"validityDays"` ValidityDays int `json:"validity_days"`
ValidityUnit string `json:"validityUnit"` ValidityUnit string `json:"validity_unit"`
Features string `json:"features"` Features string `json:"features"`
ProductName string `json:"productName"` ProductName string `json:"product_name"`
ForSale bool `json:"forSale"` ForSale bool `json:"for_sale"`
SortOrder int `json:"sortOrder"` SortOrder int `json:"sort_order"`
} }
type UpdatePlanRequest struct { type UpdatePlanRequest struct {
GroupID *int64 `json:"groupId"` GroupID *int64 `json:"group_id"`
Name *string `json:"name"` Name *string `json:"name"`
Description *string `json:"description"` Description *string `json:"description"`
Price *float64 `json:"price"` Price *float64 `json:"price"`
OriginalPrice *float64 `json:"originalPrice"` OriginalPrice *float64 `json:"original_price"`
ValidityDays *int `json:"validityDays"` ValidityDays *int `json:"validity_days"`
ValidityUnit *string `json:"validityUnit"` ValidityUnit *string `json:"validity_unit"`
Features *string `json:"features"` Features *string `json:"features"`
ProductName *string `json:"productName"` ProductName *string `json:"product_name"`
ForSale *bool `json:"forSale"` ForSale *bool `json:"for_sale"`
SortOrder *int `json:"sortOrder"` SortOrder *int `json:"sort_order"`
} }
// PaymentConfigService manages payment configuration and CRUD for // PaymentConfigService manages payment configuration and CRUD for
@@ -149,29 +183,43 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo
SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders, SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders,
SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingLoadBalanceStrategy, SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingLoadBalanceStrategy,
SettingProductNamePrefix, SettingProductNameSuffix, SettingProductNamePrefix, SettingProductNameSuffix,
SettingHelpImageURL, SettingHelpText,
SettingCancelRateLimitOn, SettingCancelRateLimitMax,
SettingCancelWindowSize, SettingCancelWindowUnit, SettingCancelWindowMode,
} }
vals, err := s.settingRepo.GetMultiple(ctx, keys) vals, err := s.settingRepo.GetMultiple(ctx, keys)
if err != nil { if err != nil {
return nil, fmt.Errorf("get payment config settings: %w", err) return nil, fmt.Errorf("get payment config settings: %w", err)
} }
return s.parsePaymentConfig(vals), nil cfg := s.parsePaymentConfig(vals)
// Load Stripe publishable key from the first enabled Stripe provider instance
cfg.StripePublishableKey = s.getStripePublishableKey(ctx)
return cfg, nil
} }
func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *PaymentConfig { func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *PaymentConfig {
cfg := &PaymentConfig{ cfg := &PaymentConfig{
Enabled: vals[SettingPaymentEnabled] == "true", Enabled: vals[SettingPaymentEnabled] == "true",
MinAmount: pcParseFloat(vals[SettingMinRechargeAmount], 1), MinAmount: pcParseFloat(vals[SettingMinRechargeAmount], 1),
MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 99999999.99), MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 0),
DailyLimit: pcParseFloat(vals[SettingDailyRechargeLimit], 0), DailyLimit: pcParseFloat(vals[SettingDailyRechargeLimit], 0),
OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], 30), OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], defaultOrderTimeoutMin),
MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], 3), MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], defaultMaxPendingOrders),
BalanceDisabled: vals[SettingBalancePayDisabled] == "true", BalanceDisabled: vals[SettingBalancePayDisabled] == "true",
LoadBalanceStrategy: vals[SettingLoadBalanceStrategy], LoadBalanceStrategy: vals[SettingLoadBalanceStrategy],
ProductNamePrefix: vals[SettingProductNamePrefix], ProductNamePrefix: vals[SettingProductNamePrefix],
ProductNameSuffix: vals[SettingProductNameSuffix], ProductNameSuffix: vals[SettingProductNameSuffix],
HelpImageURL: vals[SettingHelpImageURL],
HelpText: vals[SettingHelpText],
CancelRateLimitEnabled: vals[SettingCancelRateLimitOn] == "true",
CancelRateLimitMax: pcParseInt(vals[SettingCancelRateLimitMax], 10),
CancelRateLimitWindow: pcParseInt(vals[SettingCancelWindowSize], 1),
CancelRateLimitUnit: vals[SettingCancelWindowUnit],
CancelRateLimitMode: vals[SettingCancelWindowMode],
} }
if cfg.LoadBalanceStrategy == "" { if cfg.LoadBalanceStrategy == "" {
cfg.LoadBalanceStrategy = "round-robin" cfg.LoadBalanceStrategy = payment.DefaultLoadBalanceStrategy
} }
if raw := vals[SettingEnabledPaymentTypes]; raw != "" { if raw := vals[SettingEnabledPaymentTypes]; raw != "" {
for _, t := range strings.Split(raw, ",") { for _, t := range strings.Split(raw, ",") {
@@ -184,242 +232,100 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme
return cfg return cfg
} }
// getStripePublishableKey finds the publishable key from the first enabled Stripe provider instance.
func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) string {
instances, err := s.entClient.PaymentProviderInstance.Query().
Where(
paymentproviderinstance.EnabledEQ(true),
paymentproviderinstance.ProviderKeyEQ(payment.TypeStripe),
).Limit(1).All(ctx)
if err != nil || len(instances) == 0 {
return ""
}
cfg, err := s.decryptConfig(instances[0].Config)
if err != nil || cfg == nil {
return ""
}
return cfg[payment.ConfigKeyPublishableKey]
}
// UpdatePaymentConfig updates the payment configuration settings. // UpdatePaymentConfig updates the payment configuration settings.
// NOTE: This function exceeds 30 lines because each field requires an independent
// nil-check before serialisation — this is inherent to patch-style update patterns
// and cannot be meaningfully decomposed without introducing unnecessary abstraction.
func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req UpdatePaymentConfigRequest) error { func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req UpdatePaymentConfigRequest) error {
m := make(map[string]string) m := map[string]string{
if req.Enabled != nil { SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled),
m[SettingPaymentEnabled] = strconv.FormatBool(*req.Enabled) SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount),
} SettingMaxRechargeAmount: formatPositiveFloat(req.MaxAmount),
if req.MinAmount != nil { SettingDailyRechargeLimit: formatPositiveFloat(req.DailyLimit),
m[SettingMinRechargeAmount] = strconv.FormatFloat(*req.MinAmount, 'f', 2, 64) SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin),
} SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders),
if req.MaxAmount != nil { SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled),
m[SettingMaxRechargeAmount] = strconv.FormatFloat(*req.MaxAmount, 'f', 2, 64) SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy),
} SettingProductNamePrefix: derefStr(req.ProductNamePrefix),
if req.DailyLimit != nil { SettingProductNameSuffix: derefStr(req.ProductNameSuffix),
m[SettingDailyRechargeLimit] = strconv.FormatFloat(*req.DailyLimit, 'f', 2, 64) SettingHelpImageURL: derefStr(req.HelpImageURL),
} SettingHelpText: derefStr(req.HelpText),
if req.OrderTimeoutMin != nil { SettingCancelRateLimitOn: formatBoolOrEmpty(req.CancelRateLimitEnabled),
m[SettingOrderTimeoutMinutes] = strconv.Itoa(*req.OrderTimeoutMin) SettingCancelRateLimitMax: formatPositiveInt(req.CancelRateLimitMax),
} SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow),
if req.MaxPendingOrders != nil { SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit),
m[SettingMaxPendingOrders] = strconv.Itoa(*req.MaxPendingOrders) SettingCancelWindowMode: derefStr(req.CancelRateLimitMode),
} }
if req.EnabledTypes != nil { if req.EnabledTypes != nil {
m[SettingEnabledPaymentTypes] = strings.Join(req.EnabledTypes, ",") m[SettingEnabledPaymentTypes] = strings.Join(req.EnabledTypes, ",")
} } else {
if req.BalanceDisabled != nil { m[SettingEnabledPaymentTypes] = ""
m[SettingBalancePayDisabled] = strconv.FormatBool(*req.BalanceDisabled)
}
if req.LoadBalanceStrategy != nil {
m[SettingLoadBalanceStrategy] = *req.LoadBalanceStrategy
}
if req.ProductNamePrefix != nil {
m[SettingProductNamePrefix] = *req.ProductNamePrefix
}
if req.ProductNameSuffix != nil {
m[SettingProductNameSuffix] = *req.ProductNameSuffix
}
if len(m) == 0 {
return nil
} }
return s.settingRepo.SetMultiple(ctx, m) return s.settingRepo.SetMultiple(ctx, m)
} }
// --- Provider Instance CRUD --- func formatBoolOrEmpty(v *bool) string {
if v == nil {
func (s *PaymentConfigService) ListProviderInstances(ctx context.Context) ([]*dbent.PaymentProviderInstance, error) { return ""
return s.entClient.PaymentProviderInstance.Query().Order(paymentproviderinstance.BySortOrder()).All(ctx) }
return strconv.FormatBool(*v)
} }
func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) { func formatPositiveFloat(v *float64) string {
enc, err := s.encryptConfig(req.Config) if v == nil || *v <= 0 {
if err != nil { return "" // empty → parsePaymentConfig uses default
return nil, err
} }
return s.entClient.PaymentProviderInstance.Create(). return strconv.FormatFloat(*v, 'f', 2, 64)
SetProviderKey(req.ProviderKey).SetName(req.Name).SetConfig(enc).
SetSupportedTypes(req.SupportedTypes).SetEnabled(req.Enabled).
SetSortOrder(req.SortOrder).SetLimits(req.Limits).SetRefundEnabled(req.RefundEnabled).
Save(ctx)
} }
func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id int64, req UpdateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) { func formatPositiveInt(v *int) string {
u := s.entClient.PaymentProviderInstance.UpdateOneID(id) if v == nil || *v <= 0 {
if req.Name != nil { return ""
u.SetName(*req.Name)
} }
if req.Config != nil { return strconv.Itoa(*v)
enc, err := s.encryptConfig(req.Config)
if err != nil {
return nil, err
}
u.SetConfig(enc)
}
if req.SupportedTypes != nil {
u.SetSupportedTypes(*req.SupportedTypes)
}
if req.Enabled != nil {
u.SetEnabled(*req.Enabled)
}
if req.SortOrder != nil {
u.SetSortOrder(*req.SortOrder)
}
if req.Limits != nil {
u.SetLimits(*req.Limits)
}
if req.RefundEnabled != nil {
u.SetRefundEnabled(*req.RefundEnabled)
}
return u.Save(ctx)
} }
func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id int64) error { func derefStr(v *string) string {
return s.entClient.PaymentProviderInstance.DeleteOneID(id).Exec(ctx) if v == nil {
return ""
}
return *v
} }
func (s *PaymentConfigService) encryptConfig(cfg map[string]string) (string, error) { func splitTypes(s string) []string {
data, err := json.Marshal(cfg) if s == "" {
if err != nil { return nil
return "", fmt.Errorf("marshal config: %w", err)
} }
enc, err := payment.Encrypt(string(data), s.encryptionKey) parts := strings.Split(s, ",")
if err != nil { result := make([]string, 0, len(parts))
return "", fmt.Errorf("encrypt config: %w", err) for _, p := range parts {
} p = strings.TrimSpace(p)
return enc, nil if p != "" {
} result = append(result, p)
// --- Channel CRUD ---
// --- Plan CRUD ---
func (s *PaymentConfigService) ListPlans(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.BySortOrder()).All(ctx)
}
func (s *PaymentConfigService) ListPlansForSale(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.BySortOrder()).All(ctx)
}
func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*dbent.SubscriptionPlan, error) {
b := s.entClient.SubscriptionPlan.Create().
SetGroupID(req.GroupID).SetName(req.Name).SetDescription(req.Description).
SetPrice(req.Price).SetValidityDays(req.ValidityDays).SetValidityUnit(req.ValidityUnit).
SetFeatures(req.Features).SetProductName(req.ProductName).
SetForSale(req.ForSale).SetSortOrder(req.SortOrder)
if req.OriginalPrice != nil {
b.SetOriginalPrice(*req.OriginalPrice)
}
return b.Save(ctx)
}
func (s *PaymentConfigService) UpdatePlan(ctx context.Context, id int64, req UpdatePlanRequest) (*dbent.SubscriptionPlan, error) {
u := s.entClient.SubscriptionPlan.UpdateOneID(id)
if req.GroupID != nil {
u.SetGroupID(*req.GroupID)
}
if req.Name != nil {
u.SetName(*req.Name)
}
if req.Description != nil {
u.SetDescription(*req.Description)
}
if req.Price != nil {
u.SetPrice(*req.Price)
}
if req.OriginalPrice != nil {
u.SetOriginalPrice(*req.OriginalPrice)
}
if req.ValidityDays != nil {
u.SetValidityDays(*req.ValidityDays)
}
if req.ValidityUnit != nil {
u.SetValidityUnit(*req.ValidityUnit)
}
if req.Features != nil {
u.SetFeatures(*req.Features)
}
if req.ProductName != nil {
u.SetProductName(*req.ProductName)
}
if req.ForSale != nil {
u.SetForSale(*req.ForSale)
}
if req.SortOrder != nil {
u.SetSortOrder(*req.SortOrder)
}
return u.Save(ctx)
}
func (s *PaymentConfigService) DeletePlan(ctx context.Context, id int64) error {
return s.entClient.SubscriptionPlan.DeleteOneID(id).Exec(ctx)
}
// GetPlan returns a subscription plan by ID.
func (s *PaymentConfigService) GetPlan(ctx context.Context, id int64) (*dbent.SubscriptionPlan, error) {
plan, err := s.entClient.SubscriptionPlan.Get(ctx, id)
if err != nil {
return nil, infraerrors.NotFound("PLAN_NOT_FOUND", "subscription plan not found")
}
return plan, nil
}
// GetMethodLimits returns per-payment-type limits from enabled provider instances.
func (s *PaymentConfigService) GetMethodLimits(ctx context.Context, types []string) ([]MethodLimits, error) {
instances, err := s.entClient.PaymentProviderInstance.Query().
Where(paymentproviderinstance.EnabledEQ(true)).All(ctx)
if err != nil {
return nil, fmt.Errorf("query provider instances: %w", err)
}
result := make([]MethodLimits, 0, len(types))
for _, pt := range types {
ml := MethodLimits{PaymentType: pt}
for _, inst := range instances {
if !pcInstanceSupportsType(inst, pt) {
continue
}
pcApplyInstanceLimits(inst, pt, &ml)
}
result = append(result, ml)
}
return result, nil
}
func pcInstanceSupportsType(inst *dbent.PaymentProviderInstance, pt string) bool {
if inst.SupportedTypes == "" {
return true
}
for _, t := range strings.Split(inst.SupportedTypes, ",") {
if strings.TrimSpace(t) == pt {
return true
} }
} }
return false return result
} }
func pcApplyInstanceLimits(inst *dbent.PaymentProviderInstance, pt string, ml *MethodLimits) { func joinTypes(types []string) string {
if inst.Limits == "" { return strings.Join(types, ",")
return
}
var limits payment.InstanceLimits
if err := json.Unmarshal([]byte(inst.Limits), &limits); err != nil {
return
}
cl, ok := limits[pt]
if !ok {
return
}
if cl.DailyLimit > 0 && (ml.DailyLimit == 0 || cl.DailyLimit < ml.DailyLimit) {
ml.DailyLimit = cl.DailyLimit
}
if cl.SingleMin > 0 && (ml.SingleMin == 0 || cl.SingleMin > ml.SingleMin) {
ml.SingleMin = cl.SingleMin
}
if cl.SingleMax > 0 && (ml.SingleMax == 0 || cl.SingleMax < ml.SingleMax) {
ml.SingleMax = cl.SingleMax
}
} }
func pcParseFloat(s string, defaultVal float64) float64 { func pcParseFloat(s string, defaultVal float64) float64 {

View File

@@ -5,12 +5,9 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"math" "math"
"strconv"
"strings"
"time" "time"
dbent "github.com/Wei-Shaw/sub2api/ent" dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
"github.com/Wei-Shaw/sub2api/ent/paymentorder" "github.com/Wei-Shaw/sub2api/ent/paymentorder"
"github.com/Wei-Shaw/sub2api/internal/payment" "github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
@@ -19,20 +16,14 @@ import (
// --- Payment Notification & Fulfillment --- // --- Payment Notification & Fulfillment ---
func (s *PaymentService) HandlePaymentNotification(ctx context.Context, n *payment.PaymentNotification, pk string) error { func (s *PaymentService) HandlePaymentNotification(ctx context.Context, n *payment.PaymentNotification, pk string) error {
if n.Status != payment.NotificationStatusSuccess { if n.Status != "success" {
return nil return nil
} }
// Look up order by out_trade_no (the external order ID we sent to the provider) oid, err := parseOrderID(n.OrderID)
order, err := s.entClient.PaymentOrder.Query().Where(paymentorder.OutTradeNo(n.OrderID)).Only(ctx)
if err != nil { if err != nil {
// Fallback: try legacy format (sub2_N where N is DB ID) return fmt.Errorf("invalid order ID: %s", n.OrderID)
trimmed := strings.TrimPrefix(n.OrderID, orderIDPrefix)
if oid, parseErr := strconv.ParseInt(trimmed, 10, 64); parseErr == nil {
return s.confirmPayment(ctx, oid, n.TradeNo, n.Amount, pk)
}
return fmt.Errorf("order not found for out_trade_no: %s", n.OrderID)
} }
return s.confirmPayment(ctx, order.ID, n.TradeNo, n.Amount, pk) return s.confirmPayment(ctx, oid, n.TradeNo, n.Amount, pk)
} }
func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo string, paid float64, pk string) error { func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo string, paid float64, pk string) error {
@@ -41,17 +32,9 @@ func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo
slog.Error("order not found", "orderID", oid) slog.Error("order not found", "orderID", oid)
return nil return nil
} }
// Skip amount check when paid=0 (e.g. QueryOrder doesn't return amount). if math.Abs(paid-o.PayAmount) > amountToleranceCNY {
// Also skip if paid is NaN/Inf (malformed provider data). s.writeAuditLog(ctx, o.ID, "PAYMENT_AMOUNT_MISMATCH", pk, map[string]any{"expected": o.PayAmount, "paid": paid, "tradeNo": tradeNo})
if paid > 0 && !math.IsNaN(paid) && !math.IsInf(paid, 0) { return fmt.Errorf("amount mismatch: expected %.2f, got %.2f", o.PayAmount, paid)
if math.Abs(paid-o.PayAmount) > amountToleranceCNY {
s.writeAuditLog(ctx, o.ID, "PAYMENT_AMOUNT_MISMATCH", pk, map[string]any{"expected": o.PayAmount, "paid": paid, "tradeNo": tradeNo})
return fmt.Errorf("amount mismatch: expected %.2f, got %.2f", o.PayAmount, paid)
}
}
// Use order's expected amount when provider didn't report one
if paid <= 0 || math.IsNaN(paid) || math.IsInf(paid, 0) {
paid = o.PayAmount
} }
return s.toPaid(ctx, o, tradeNo, paid, pk) return s.toPaid(ctx, o, tradeNo, paid, pk)
} }
@@ -129,7 +112,7 @@ func (s *PaymentService) executeFulfillment(ctx context.Context, oid int64) erro
if err != nil { if err != nil {
return fmt.Errorf("get order: %w", err) return fmt.Errorf("get order: %w", err)
} }
if o.OrderType == payment.OrderTypeSubscription { if o.OrderType == "subscription" {
return s.ExecuteSubscriptionFulfillment(ctx, oid) return s.ExecuteSubscriptionFulfillment(ctx, oid)
} }
return s.ExecuteBalanceFulfillment(ctx, oid) return s.ExecuteBalanceFulfillment(ctx, oid)
@@ -163,46 +146,20 @@ func (s *PaymentService) ExecuteBalanceFulfillment(ctx context.Context, oid int6
return nil return nil
} }
// redeemAction represents the idempotency decision for balance fulfillment.
type redeemAction int
const (
// redeemActionCreate: code does not exist — create it, then redeem.
redeemActionCreate redeemAction = iota
// redeemActionRedeem: code exists but is unused — skip creation, redeem only.
redeemActionRedeem
// redeemActionSkipCompleted: code exists and is already used — skip to mark completed.
redeemActionSkipCompleted
)
// resolveRedeemAction decides the idempotency action based on an existing redeem code lookup.
// existing is the result of GetByCode; lookupErr is the error from that call.
func resolveRedeemAction(existing *RedeemCode, lookupErr error) redeemAction {
if existing == nil || lookupErr != nil {
return redeemActionCreate
}
if existing.IsUsed() {
return redeemActionSkipCompleted
}
return redeemActionRedeem
}
func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) error { func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) error {
// Idempotency: check if redeem code already exists (from a previous partial run) // Idempotency: check if redeem code already exists (from a previous partial run)
existing, lookupErr := s.redeemService.GetByCode(ctx, o.RechargeCode) existing, _ := s.redeemService.GetByCode(ctx, o.RechargeCode)
action := resolveRedeemAction(existing, lookupErr) if existing != nil {
if existing.IsUsed() {
switch action { // Code already created and redeemed — just mark completed
case redeemActionSkipCompleted: return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
// Code already created and redeemed — just mark completed }
return s.markCompleted(ctx, o, "RECHARGE_SUCCESS") // Code exists but unused — skip creation, proceed to redeem
case redeemActionCreate: } else {
rc := &RedeemCode{Code: o.RechargeCode, Type: RedeemTypeBalance, Value: o.Amount, Status: StatusUnused} rc := &RedeemCode{Code: o.RechargeCode, Type: RedeemTypeBalance, Value: o.Amount, Status: StatusUnused}
if err := s.redeemService.CreateCode(ctx, rc); err != nil { if err := s.redeemService.CreateCode(ctx, rc); err != nil {
return fmt.Errorf("create redeem code: %w", err) return fmt.Errorf("create redeem code: %w", err)
} }
case redeemActionRedeem:
// Code exists but unused — skip creation, proceed to redeem
} }
if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil { if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil {
return fmt.Errorf("redeem balance: %w", err) return fmt.Errorf("redeem balance: %w", err)
@@ -255,45 +212,30 @@ func (s *PaymentService) doSub(ctx context.Context, o *dbent.PaymentOrder) error
gid := *o.SubscriptionGroupID gid := *o.SubscriptionGroupID
days := *o.SubscriptionDays days := *o.SubscriptionDays
g, err := s.groupRepo.GetByID(ctx, gid) g, err := s.groupRepo.GetByID(ctx, gid)
if err != nil || g.Status != payment.EntityStatusActive { if err != nil || g.Status != "active" {
return fmt.Errorf("group %d no longer exists or inactive", gid) return fmt.Errorf("group %d no longer exists or inactive", gid)
} }
// Idempotency: check audit log to see if subscription was already assigned. _, _, err = s.subscriptionSvc.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{UserID: o.UserID, GroupID: gid, ValidityDays: days, AssignedBy: 0, Notes: fmt.Sprintf("payment order %d", o.ID)})
// Prevents double-extension on retry after markCompleted fails.
if s.hasAuditLog(ctx, o.ID, "SUBSCRIPTION_SUCCESS") {
slog.Info("subscription already assigned for order, skipping", "orderID", o.ID, "groupID", gid)
return s.markCompleted(ctx, o, "SUBSCRIPTION_SUCCESS")
}
orderNote := fmt.Sprintf("payment order %d", o.ID)
_, _, err = s.subscriptionSvc.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{UserID: o.UserID, GroupID: gid, ValidityDays: days, AssignedBy: 0, Notes: orderNote})
if err != nil { if err != nil {
return fmt.Errorf("assign subscription: %w", err) return fmt.Errorf("assign subscription: %w", err)
} }
return s.markCompleted(ctx, o, "SUBSCRIPTION_SUCCESS") now := time.Now()
} _, err = s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(o.ID), paymentorder.StatusEQ(OrderStatusRecharging)).SetStatus(OrderStatusCompleted).SetCompletedAt(now).Save(ctx)
if err != nil {
func (s *PaymentService) hasAuditLog(ctx context.Context, orderID int64, action string) bool { return fmt.Errorf("mark completed: %w", err)
oid := strconv.FormatInt(orderID, 10) }
c, _ := s.entClient.PaymentAuditLog.Query(). s.writeAuditLog(ctx, o.ID, "SUBSCRIPTION_SUCCESS", "system", map[string]any{"groupId": gid, "days": days, "amount": o.Amount})
Where(paymentauditlog.OrderIDEQ(oid), paymentauditlog.ActionEQ(action)). return nil
Limit(1).Count(ctx)
return c > 0
} }
func (s *PaymentService) markFailed(ctx context.Context, oid int64, cause error) { func (s *PaymentService) markFailed(ctx context.Context, oid int64, cause error) {
now := time.Now() now := time.Now()
r := psErrMsg(cause) r := psErrMsg(cause)
// Only mark FAILED if still in RECHARGING state — prevents overwriting _, e := s.entClient.PaymentOrder.UpdateOneID(oid).SetStatus(OrderStatusFailed).SetFailedAt(now).SetFailedReason(r).Save(ctx)
// a COMPLETED order when markCompleted failed but fulfillment succeeded.
c, e := s.entClient.PaymentOrder.Update().
Where(paymentorder.IDEQ(oid), paymentorder.StatusEQ(OrderStatusRecharging)).
SetStatus(OrderStatusFailed).SetFailedAt(now).SetFailedReason(r).Save(ctx)
if e != nil { if e != nil {
slog.Error("mark FAILED", "orderID", oid, "error", e) slog.Error("mark FAILED", "orderID", oid, "error", e)
} }
if c > 0 { s.writeAuditLog(ctx, oid, "FULFILLMENT_FAILED", "system", map[string]any{"reason": r})
s.writeAuditLog(ctx, oid, "FULFILLMENT_FAILED", "system", map[string]any{"reason": r})
}
} }
func (s *PaymentService) RetryFulfillment(ctx context.Context, oid int64) error { func (s *PaymentService) RetryFulfillment(ctx context.Context, oid int64) error {

View File

@@ -42,7 +42,7 @@
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.methods.' + method.type, method.type) }}</span> <span class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.methods.' + method.type, method.type) }}</span>
</div> </div>
<div class="text-right"> <div class="text-right">
<span class="text-sm font-medium text-gray-900 dark:text-white">${{ method.amount.toFixed(2) }}</span> <span class="text-sm font-medium text-gray-900 dark:text-white">&yen;{{ method.amount.toFixed(2) }}</span>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">({{ method.count }})</span> <span class="ml-2 text-xs text-gray-500 dark:text-gray-400">({{ method.count }})</span>
</div> </div>
</div> </div>
@@ -57,7 +57,7 @@
<span :class="['flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold', rankClass(idx)]">{{ idx + 1 }}</span> <span :class="['flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold', rankClass(idx)]">{{ idx + 1 }}</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ user.email }}</span> <span class="text-sm text-gray-700 dark:text-gray-300">{{ user.email }}</span>
</div> </div>
<span class="text-sm font-medium text-gray-900 dark:text-white">${{ user.amount.toFixed(2) }}</span> <span class="text-sm font-medium text-gray-900 dark:text-white">&yen;{{ user.amount.toFixed(2) }}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,217 +5,82 @@
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div> <div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
</div> </div>
<template v-else> <template v-else>
<!-- Tab Switcher (hide during payment and subscription confirm) --> <!-- Balance Card -->
<div v-if="tabs.length > 1 && paymentPhase === 'select' && !selectedPlan" class="flex space-x-1 rounded-xl bg-gray-100 p-1 dark:bg-dark-800"> <div class="card overflow-hidden">
<div class="bg-gradient-to-br from-primary-500 to-primary-600 px-6 py-6 text-center">
<p class="text-sm font-medium text-primary-100">{{ t('payment.currentBalance') }}</p>
<p class="mt-1 text-3xl font-bold text-white">${{ user?.balance?.toFixed(2) || '0.00' }}</p>
</div>
</div>
<!-- Tab Switcher -->
<div v-if="tabs.length > 1" class="flex space-x-1 rounded-xl bg-gray-100 p-1 dark:bg-dark-800">
<button v-for="tab in tabs" :key="tab.key" <button v-for="tab in tabs" :key="tab.key"
class="flex-1 rounded-lg px-4 py-2.5 text-sm font-medium transition-all" class="flex-1 rounded-lg px-4 py-2.5 text-sm font-medium transition-all"
:class="activeTab === tab.key ? 'bg-white text-gray-900 shadow dark:bg-dark-700 dark:text-white' : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'" :class="activeTab === tab.key ? 'bg-white text-gray-900 shadow dark:bg-dark-700 dark:text-white' : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'"
@click="activeTab = tab.key">{{ tab.label }}</button> @click="activeTab = tab.key">{{ tab.label }}</button>
</div> </div>
<!-- Payment in progress (shared by recharge and subscription) --> <!-- Top-up Tab -->
<template v-if="paymentPhase === 'paying'"> <template v-if="activeTab === 'recharge'">
<PaymentStatusPanel <!-- No payment methods available -->
:order-id="paymentState.orderId" <div v-if="enabledMethods.length === 0" class="card py-16 text-center">
:qr-code="paymentState.qrCode" <p class="text-gray-500 dark:text-gray-400">{{ t('payment.notAvailable') }}</p>
:expires-at="paymentState.expiresAt" </div>
:payment-type="paymentState.paymentType" <template v-else>
:pay-url="paymentState.payUrl" <div class="card p-6">
:order-type="paymentState.orderType" <AmountInput
@done="onPaymentDone" v-model="amount"
@success="onPaymentSuccess" :amounts="[10, 20, 50, 100, 200, 500, 1000, 2000, 5000]"
/> :min="activeMinAmount"
</template> :max="activeMaxAmount"
<template v-else-if="paymentPhase === 'stripe'"> />
<StripePaymentInline <p v-if="amountError" class="mt-2 text-xs text-amber-600 dark:text-amber-300">{{ amountError }}</p>
:order-id="paymentState.orderId" </div>
:client-secret="paymentState.clientSecret" <div v-if="enabledMethods.length >= 1" class="card p-6">
:publishable-key="checkout.stripe_publishable_key" <PaymentMethodSelector
:pay-amount="paymentState.payAmount" :methods="methodOptions"
@success="onPaymentSuccess" :selected="selectedMethod"
@done="onStripeDone" @select="selectedMethod = $event"
@back="resetPayment" />
@redirect="onStripeRedirect" </div>
/> <div v-if="feeRate > 0 && validAmount > 0" class="card p-6">
</template> <div class="space-y-2 text-sm">
<!-- Tab content (select phase) --> <div class="flex justify-between">
<template v-else> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span>
<!-- Top-up Tab --> <span class="text-gray-900 dark:text-white">¥{{ validAmount.toFixed(2) }}</span>
<template v-if="activeTab === 'recharge'"> </div>
<!-- Recharge Account Card --> <div class="flex justify-between">
<div class="card p-5"> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
<p class="text-xs font-medium text-gray-400 dark:text-gray-500">{{ t('payment.rechargeAccount') }}</p> <span class="text-gray-900 dark:text-white">¥{{ feeAmount.toFixed(2) }}</span>
<p class="mt-1 text-base font-semibold text-gray-900 dark:text-white">{{ user?.username || '' }}</p> </div>
<p class="mt-0.5 text-sm font-medium text-green-600 dark:text-green-400">{{ t('payment.currentBalance') }}: {{ user?.balance?.toFixed(2) || '0.00' }}</p> <div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
</div> <span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
<div v-if="enabledMethods.length === 0" class="card py-16 text-center"> <span class="text-lg font-bold text-primary-600 dark:text-primary-400">¥{{ totalAmount.toFixed(2) }}</span>
<p class="text-gray-500 dark:text-gray-400">{{ t('payment.notAvailable') }}</p>
</div>
<template v-else>
<div class="card p-6">
<AmountInput
v-model="amount"
:amounts="[10, 20, 50, 100, 200, 500, 1000, 2000, 5000]"
:min="globalMinAmount"
:max="globalMaxAmount"
/>
<p v-if="amountError" class="mt-2 text-xs text-amber-600 dark:text-amber-300">{{ amountError }}</p>
</div>
<div v-if="enabledMethods.length >= 1" class="card p-6">
<PaymentMethodSelector
:methods="methodOptions"
:selected="selectedMethod"
@select="selectedMethod = $event"
/>
</div>
<div v-if="feeRate > 0 && validAmount > 0" class="card p-6">
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span>
<span class="text-gray-900 dark:text-white">${{ validAmount.toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
<span class="text-gray-900 dark:text-white">${{ feeAmount.toFixed(2) }}</span>
</div>
<div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">${{ totalAmount.toFixed(2) }}</span>
</div>
</div> </div>
</div> </div>
<button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmit || submitting" @click="handleSubmitRecharge"> </div>
<span v-if="submitting" class="flex items-center justify-center gap-2"> <button class="btn btn-primary w-full py-3 text-base font-medium" :disabled="!canSubmit || submitting" @click="handleSubmitRecharge">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span> <span v-if="submitting" class="flex items-center justify-center gap-2">
{{ t('common.processing') }} <span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
</span> {{ t('common.processing') }}
<span v-else>{{ t('payment.createOrder') }} ${{ (feeRate > 0 && validAmount > 0 ? totalAmount : validAmount).toFixed(2) }}</span> </span>
</button> <span v-else>{{ t('payment.createOrder') }} ¥{{ (feeRate > 0 && validAmount > 0 ? totalAmount : validAmount).toFixed(2) }}</span>
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"> </button>
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p> <div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
</div> <p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
</template> </div>
</template>
<!-- Subscribe Tab -->
<template v-else-if="activeTab === 'subscription'">
<!-- Subscription confirm (inline, replaces plan list) -->
<template v-if="selectedPlan">
<div class="card p-5">
<!-- Header: platform badge + plan name -->
<div class="mb-3 flex flex-wrap items-center gap-2">
<span :class="['rounded-md border px-2 py-0.5 text-xs font-medium', planBadgeClass]">
{{ platformLabel(selectedPlan.group_platform || '') }}
</span>
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{{ selectedPlan.name }}</h3>
</div>
<!-- Price -->
<div class="flex items-baseline gap-2">
<span v-if="selectedPlan.original_price" class="text-sm text-gray-400 line-through dark:text-gray-500">
${{ selectedPlan.original_price }}
</span>
<span :class="['text-3xl font-bold', planTextClass]">${{ selectedPlan.price }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">/ {{ planValiditySuffix }}</span>
</div>
<!-- Description -->
<p v-if="selectedPlan.description" class="mt-2 text-sm leading-relaxed text-gray-500 dark:text-gray-400">
{{ selectedPlan.description }}
</p>
<!-- Rate + Limits grid -->
<div class="mt-3 grid grid-cols-2 gap-3">
<div>
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.rate') }}</span>
<div class="flex items-baseline">
<span :class="['text-lg font-bold', planTextClass]">×{{ selectedPlan.rate_multiplier ?? 1 }}</span>
</div>
</div>
<div v-if="selectedPlan.daily_limit_usd != null">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.dailyLimit') }}</span>
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">${{ selectedPlan.daily_limit_usd }}</div>
</div>
<div v-if="selectedPlan.weekly_limit_usd != null">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.weeklyLimit') }}</span>
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">${{ selectedPlan.weekly_limit_usd }}</div>
</div>
<div v-if="selectedPlan.monthly_limit_usd != null">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.monthlyLimit') }}</span>
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">${{ selectedPlan.monthly_limit_usd }}</div>
</div>
<div v-if="selectedPlan.daily_limit_usd == null && selectedPlan.weekly_limit_usd == null && selectedPlan.monthly_limit_usd == null">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.quota') }}</span>
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">{{ t('payment.planCard.unlimited') }}</div>
</div>
</div>
</div>
<div v-if="enabledMethods.length >= 1" class="card p-6">
<PaymentMethodSelector
:methods="subMethodOptions"
:selected="selectedMethod"
@select="selectedMethod = $event"
/>
</div>
<div v-if="feeRate > 0 && selectedPlan.price > 0" class="card p-6">
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span>
<span class="text-gray-900 dark:text-white">${{ selectedPlan.price.toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
<span class="text-gray-900 dark:text-white">${{ subFeeAmount.toFixed(2) }}</span>
</div>
<div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">${{ subTotalAmount.toFixed(2) }}</span>
</div>
</div>
</div>
<button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmitSubscription || submitting" @click="confirmSubscribe">
<span v-if="submitting" class="flex items-center justify-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{{ t('common.processing') }}
</span>
<span v-else>{{ t('payment.createOrder') }} ${{ (feeRate > 0 ? subTotalAmount : selectedPlan.price).toFixed(2) }}</span>
</button>
<button class="btn btn-secondary w-full" @click="selectedPlan = null">{{ t('common.cancel') }}</button>
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
</div>
</template>
<!-- Plan list -->
<template v-else>
<div v-if="checkout.plans.length === 0" class="card py-16 text-center">
<Icon name="gift" size="xl" class="mx-auto mb-3 text-gray-300 dark:text-dark-600" />
<p class="text-gray-500 dark:text-gray-400">{{ t('payment.noPlans') }}</p>
</div>
<div v-else :class="planGridClass">
<SubscriptionPlanCard v-for="plan in checkout.plans" :key="plan.id" :plan="plan" :active-subscriptions="activeSubscriptions" @select="selectPlan" />
</div>
<!-- Active subscriptions (compact, below plan list) -->
<div v-if="activeSubscriptions.length > 0">
<p class="mb-2 text-xs font-medium text-gray-400 dark:text-gray-500">{{ t('payment.activeSubscription') }}</p>
<div class="space-y-2">
<div v-for="sub in activeSubscriptions" :key="sub.id"
class="flex items-center gap-3 rounded-xl border border-gray-100 bg-white px-3 py-2 dark:border-dark-700 dark:bg-dark-800">
<div :class="['h-6 w-1 shrink-0 rounded-full', platformAccentBarClass(sub.group?.platform || '')]" />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="truncate text-xs font-semibold text-gray-900 dark:text-white">{{ sub.group?.name || `Group #${sub.group_id}` }}</span>
<span :class="['shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-medium', platformBadgeLightClass(sub.group?.platform || '')]">{{ platformLabel(sub.group?.platform || '') }}</span>
</div>
<div class="flex flex-wrap gap-x-3 text-[11px] text-gray-400 dark:text-gray-500">
<span>{{ t('payment.planCard.rate') }}: ×{{ sub.group?.rate_multiplier ?? 1 }}</span>
<span v-if="sub.group?.daily_limit_usd == null && sub.group?.weekly_limit_usd == null && sub.group?.monthly_limit_usd == null">{{ t('payment.planCard.quota') }}: {{ t('payment.planCard.unlimited') }}</span>
<span v-if="sub.expires_at">{{ t('userSubscriptions.daysRemaining', { days: getDaysRemaining(sub.expires_at) }) }}</span>
<span v-else>{{ t('userSubscriptions.noExpiration') }}</span>
</div>
</div>
<span class="badge badge-success shrink-0 text-[10px]">{{ t('userSubscriptions.status.active') }}</span>
</div>
</div>
</div>
</template>
</template> </template>
</template> </template>
<div v-if="(checkout.help_text || checkout.help_image_url) && paymentPhase === 'select' && !selectedPlan" class="card p-4"> <!-- Subscribe Tab -->
<template v-else-if="activeTab === 'subscription'">
<div v-if="checkout.plans.length === 0" class="card py-16 text-center">
<Icon name="gift" size="xl" class="mx-auto mb-3 text-gray-300 dark:text-dark-600" />
<p class="text-gray-500 dark:text-gray-400">{{ t('payment.noPlans') }}</p>
</div>
<div v-else :class="planGridClass">
<SubscriptionPlanCard v-for="plan in checkout.plans" :key="plan.id" :plan="plan" @select="openSubscribeDialog" />
</div>
</template>
<div v-if="checkout.help_text || checkout.help_image_url" class="card p-4">
<div class="flex flex-col items-center gap-3"> <div class="flex flex-col items-center gap-3">
<img v-if="checkout.help_image_url" :src="checkout.help_image_url" alt="" <img v-if="checkout.help_image_url" :src="checkout.help_image_url" alt=""
class="h-40 max-w-full cursor-pointer rounded-lg object-contain transition-opacity hover:opacity-80" class="h-40 max-w-full cursor-pointer rounded-lg object-contain transition-opacity hover:opacity-80"
@@ -225,23 +90,44 @@
</div> </div>
</template> </template>
</div> </div>
<!-- Renewal Plan Selection Modal --> <!-- Subscription Confirm Dialog -->
<Teleport to="body"> <BaseDialog :show="!!selectedPlan" :title="t('payment.confirmSubscription')" @close="selectedPlan = null">
<Transition name="modal"> <div v-if="selectedPlan" class="space-y-4">
<div v-if="showRenewalModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" @click.self="closeRenewalModal"> <div class="rounded-xl border border-gray-100 bg-gray-50 p-5 dark:border-dark-700 dark:bg-dark-800">
<div class="relative w-full max-w-lg rounded-2xl border border-gray-200 bg-white p-6 shadow-2xl dark:border-dark-700 dark:bg-dark-900"> <p class="font-semibold text-gray-900 dark:text-white">{{ selectedPlan.name }}</p>
<!-- Close button --> <div class="mt-2 flex items-baseline gap-1.5">
<button class="absolute right-4 top-4 rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700 dark:hover:text-gray-200" @click="closeRenewalModal"> <span class="text-3xl font-extrabold text-primary-600 dark:text-primary-400">&yen;{{ selectedPlan.price }}</span>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg> <span v-if="selectedPlan.original_price" class="text-sm text-gray-400 line-through">&yen;{{ selectedPlan.original_price }}</span>
</button>
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.selectPlan') }}</h3>
<div class="space-y-4">
<SubscriptionPlanCard v-for="plan in renewalPlans" :key="plan.id" :plan="plan" :active-subscriptions="activeSubscriptions" @select="selectPlanFromModal" />
</div>
</div> </div>
<p v-if="selectedPlan.description" class="mt-2 text-sm text-gray-500 dark:text-dark-400">{{ selectedPlan.description }}</p>
</div> </div>
</Transition> <PaymentMethodSelector
</Teleport> v-if="enabledMethods.length > 1"
:methods="methodOptions"
:selected="selectedMethod"
@select="selectedMethod = $event"
/>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button class="btn btn-secondary" @click="selectedPlan = null">{{ t('common.cancel') }}</button>
<button class="btn btn-primary" :disabled="submitting" @click="confirmSubscribe">
{{ submitting ? t('common.processing') : t('payment.createOrder') }}
</button>
</div>
</template>
</BaseDialog>
<!-- Inline QR Payment Dialog -->
<PaymentQRDialog
:show="qrDialog.show"
:order-id="qrDialog.orderId"
:qr-code="qrDialog.qrCode"
:expires-at="qrDialog.expiresAt"
:payment-type="qrDialog.paymentType"
:pay-url="qrDialog.payUrl"
@close="qrDialog.show = false"
@success="authStore.refreshUser()"
/>
<!-- Image Preview Overlay --> <!-- Image Preview Overlay -->
<Teleport to="body"> <Teleport to="body">
<Transition name="modal"> <Transition name="modal">
@@ -256,40 +142,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { usePaymentStore } from '@/stores/payment' import { usePaymentStore } from '@/stores/payment'
import { useSubscriptionStore } from '@/stores/subscriptions'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment' import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError' import { extractApiErrorMessage } from '@/utils/apiError'
import { isMobileDevice } from '@/utils/device'
import type { SubscriptionPlan, CheckoutInfoResponse } from '@/types/payment' import type { SubscriptionPlan, CheckoutInfoResponse } from '@/types/payment'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import AmountInput from '@/components/payment/AmountInput.vue' import AmountInput from '@/components/payment/AmountInput.vue'
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue' import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
import { METHOD_ORDER, POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig' import { METHOD_ORDER, POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, platformTextClass, platformLabel } from '@/utils/platformColors'
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue' import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue' import PaymentQRDialog from '@/components/payment/PaymentQRDialog.vue'
import StripePaymentInline from '@/components/payment/StripePaymentInline.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue' import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const paymentStore = usePaymentStore() const paymentStore = usePaymentStore()
const subscriptionStore = useSubscriptionStore()
const appStore = useAppStore() const appStore = useAppStore()
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
const activeSubscriptions = computed(() => subscriptionStore.activeSubscriptions)
function getDaysRemaining(expiresAt: string): number {
const diff = new Date(expiresAt).getTime() - Date.now()
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)))
}
const loading = ref(true) const loading = ref(true)
const submitting = ref(false) const submitting = ref(false)
@@ -300,44 +176,8 @@ const selectedMethod = ref('')
const selectedPlan = ref<SubscriptionPlan | null>(null) const selectedPlan = ref<SubscriptionPlan | null>(null)
const previewImage = ref('') const previewImage = ref('')
// Payment phase: 'select' → 'paying' (QR/redirect) or 'stripe' (inline Stripe) // Inline QR payment dialog state
const paymentPhase = ref<'select' | 'paying' | 'stripe'>('select') const qrDialog = ref({ show: false, orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '' })
const paymentState = ref({ orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' })
function resetPayment() {
paymentPhase.value = 'select'
paymentState.value = { orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' }
}
function onPaymentDone() {
const wasSubscription = paymentState.value.orderType === 'subscription'
resetPayment()
selectedPlan.value = null
if (wasSubscription) {
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
}
}
function onPaymentSuccess() {
authStore.refreshUser()
if (paymentState.value.orderType === 'subscription') {
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
}
}
function onStripeDone() {
const wasSubscription = paymentState.value.orderType === 'subscription'
resetPayment()
selectedPlan.value = null
if (wasSubscription) {
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
}
}
function onStripeRedirect(orderId: number, payUrl: string) {
paymentState.value = { ...paymentState.value, orderId, payUrl, qrCode: '' }
paymentPhase.value = 'paying'
}
// All checkout data from single API call // All checkout data from single API call
const checkout = ref<CheckoutInfoResponse>({ const checkout = ref<CheckoutInfoResponse>({
@@ -358,7 +198,8 @@ const validAmount = computed(() => amount.value ?? 0)
// Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+ // Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+
const planGridClass = computed(() => { const planGridClass = computed(() => {
const n = checkout.value.plans.length const n = checkout.value.plans.length
if (n <= 2) return 'grid grid-cols-1 gap-5 sm:grid-cols-2' if (n === 1) return 'mx-auto grid max-w-sm grid-cols-1 gap-5'
if (n === 2) return 'mx-auto grid max-w-2xl grid-cols-1 gap-5 sm:grid-cols-2'
return 'grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3' return 'grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3'
}) })
@@ -372,9 +213,15 @@ function amountFitsMethod(amt: number, methodType: string): boolean {
return true return true
} }
// Global range for AmountInput (union of all methods, precomputed by backend) // Amount range: use selected method's limits when available, fallback to global
const globalMinAmount = computed(() => checkout.value.global_min) const activeMinAmount = computed(() => {
const globalMaxAmount = computed(() => checkout.value.global_max) const ml = selectedLimit.value
return ml?.single_min && ml.single_min > 0 ? ml.single_min : checkout.value.global_min
})
const activeMaxAmount = computed(() => {
const ml = selectedLimit.value
return ml?.single_max && ml.single_max > 0 ? ml.single_max : checkout.value.global_max
})
// Selected method's limits (for validation and error messages) // Selected method's limits (for validation and error messages)
const selectedLimit = computed(() => checkout.value.methods[selectedMethod.value]) const selectedLimit = computed(() => checkout.value.methods[selectedMethod.value])
@@ -423,37 +270,6 @@ const canSubmit = computed(() =>
&& selectedLimit.value?.available !== false && selectedLimit.value?.available !== false
) )
// Subscription-specific: method options based on plan price
const subMethodOptions = computed<PaymentMethodOption[]>(() => {
const planPrice = selectedPlan.value?.price ?? 0
return enabledMethods.value.map((type) => {
const ml = checkout.value.methods[type]
return {
type,
fee_rate: ml?.fee_rate ?? 0,
available: ml?.available !== false && amountFitsMethod(planPrice, type),
}
})
})
const subFeeAmount = computed(() => {
const price = selectedPlan.value?.price ?? 0
if (feeRate.value <= 0 || price <= 0) return 0
return Math.ceil(((price * feeRate.value) / 100) * 100) / 100
})
const subTotalAmount = computed(() => {
const price = selectedPlan.value?.price ?? 0
if (feeRate.value <= 0 || price <= 0) return price
return Math.round((price + subFeeAmount.value) * 100) / 100
})
const canSubmitSubscription = computed(() =>
selectedPlan.value !== null
&& amountFitsMethod(selectedPlan.value.price, selectedMethod.value)
&& selectedLimit.value?.available !== false
)
// Auto-switch to first available method when current selection can't handle the amount // Auto-switch to first available method when current selection can't handle the amount
watch(() => [validAmount.value, selectedMethod.value] as const, ([amt, method]) => { watch(() => [validAmount.value, selectedMethod.value] as const, ([amt, method]) => {
if (amt <= 0 || amountFitsMethod(amt, method)) return if (amt <= 0 || amountFitsMethod(amt, method)) return
@@ -461,51 +277,8 @@ watch(() => [validAmount.value, selectedMethod.value] as const, ([amt, method])
if (available) selectedMethod.value = available if (available) selectedMethod.value = available
}) })
// Payment button class: follows selected payment method color function openSubscribeDialog(plan: SubscriptionPlan) {
const paymentButtonClass = computed(() => {
const m = selectedMethod.value
if (!m) return 'btn-primary'
if (m.includes('alipay')) return 'btn-alipay'
if (m.includes('wxpay')) return 'btn-wxpay'
if (m === 'stripe') return 'btn-stripe'
return 'btn-primary'
})
// Subscription confirm: platform accent colors (clean card, no gradient)
const planBadgeClass = computed(() => platformBadgeClass(selectedPlan.value?.group_platform || ''))
const planTextClass = computed(() => platformTextClass(selectedPlan.value?.group_platform || ''))
// Renewal modal state
const showRenewalModal = ref(false)
const renewGroupId = ref<number | null>(null)
const renewalPlans = computed(() => {
if (renewGroupId.value == null) return []
return checkout.value.plans.filter(p => p.group_id === renewGroupId.value)
})
const planValiditySuffix = computed(() => {
if (!selectedPlan.value) return ''
const u = selectedPlan.value.validity_unit || 'day'
if (u === 'month') return t('payment.perMonth')
if (u === 'year') return t('payment.perYear')
return `${selectedPlan.value.validity_days}${t('payment.days')}`
})
function selectPlan(plan: SubscriptionPlan) {
selectedPlan.value = plan selectedPlan.value = plan
errorMessage.value = ''
}
function selectPlanFromModal(plan: SubscriptionPlan) {
showRenewalModal.value = false
renewGroupId.value = null
selectedPlan.value = plan
errorMessage.value = ''
}
function closeRenewalModal() {
showRenewalModal.value = false
renewGroupId.value = null
} }
async function handleSubmitRecharge() { async function handleSubmitRecharge() {
@@ -516,6 +289,7 @@ async function handleSubmitRecharge() {
async function confirmSubscribe() { async function confirmSubscribe() {
if (!selectedPlan.value || submitting.value) return if (!selectedPlan.value || submitting.value) return
await createOrder(selectedPlan.value.price, 'subscription', selectedPlan.value.id) await createOrder(selectedPlan.value.price, 'subscription', selectedPlan.value.id)
selectedPlan.value = null
} }
async function createOrder(orderAmount: number, orderType: string, planId?: number) { async function createOrder(orderAmount: number, orderType: string, planId?: number) {
@@ -528,51 +302,42 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
order_type: orderType, order_type: orderType,
plan_id: planId, plan_id: planId,
}) })
const openWindow = (url: string) => {
const win = window.open(url, 'paymentPopup', POPUP_WINDOW_FEATURES)
if (!win || win.closed) {
window.location.href = url
}
}
if (result.client_secret) { if (result.client_secret) {
// Stripe: show Payment Element inline (user picks method → confirms → redirect if needed) // Stripe: open in popup window, show waiting dialog on main page
paymentState.value = { const stripeUrl = router.resolve({
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '', path: '/payment/stripe',
paymentType: selectedMethod.value, payUrl: '', query: { order_id: String(result.order_id), client_secret: result.client_secret },
clientSecret: result.client_secret, payAmount: result.pay_amount, }).href
orderType, window.open(stripeUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
qrDialog.value = {
show: true,
orderId: result.order_id,
qrCode: '',
expiresAt: '',
paymentType: selectedMethod.value,
payUrl: stripeUrl,
} }
paymentPhase.value = 'stripe'
} else if (isMobileDevice() && result.pay_url) {
// Mobile + pay_url: redirect directly instead of QR/popup (mobile browsers block popups)
paymentState.value = {
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
paymentType: selectedMethod.value, payUrl: result.pay_url,
clientSecret: '', payAmount: 0,
orderType,
}
paymentPhase.value = 'paying'
window.location.href = result.pay_url
return
} else if (result.qr_code) { } else if (result.qr_code) {
// QR mode: show QR code inline // QR mode: show inline dialog, no page navigation
paymentState.value = { qrDialog.value = {
orderId: result.order_id, qrCode: result.qr_code, show: true,
expiresAt: result.expires_at || '', paymentType: selectedMethod.value, payUrl: '', orderId: result.order_id,
clientSecret: '', payAmount: 0, qrCode: result.qr_code,
orderType, expiresAt: result.expires_at || '',
paymentType: selectedMethod.value,
payUrl: '',
} }
paymentPhase.value = 'paying'
} else if (result.pay_url) { } else if (result.pay_url) {
// Redirect/popup mode: open payment URL, show waiting state inline // Redirect mode: open in popup window, show waiting dialog on main page
openWindow(result.pay_url) window.open(result.pay_url, 'paymentPopup', POPUP_WINDOW_FEATURES)
paymentState.value = { qrDialog.value = {
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '', show: true,
paymentType: selectedMethod.value, payUrl: result.pay_url, orderId: result.order_id,
clientSecret: '', payAmount: 0, qrCode: '',
orderType, expiresAt: result.expires_at || '',
paymentType: selectedMethod.value,
payUrl: result.pay_url,
} }
paymentPhase.value = 'paying'
} else { } else {
errorMessage.value = t('payment.result.failed') errorMessage.value = t('payment.result.failed')
appStore.showError(errorMessage.value) appStore.showError(errorMessage.value)
@@ -609,23 +374,7 @@ onMounted(async () => {
if (checkout.value.balance_disabled) { if (checkout.value.balance_disabled) {
activeTab.value = 'subscription' activeTab.value = 'subscription'
} }
// Handle renewal navigation: ?tab=subscription&group=123 } catch (err: unknown) { console.error('Failed to load checkout info:', err) }
if (route.query.tab === 'subscription') {
activeTab.value = 'subscription'
if (route.query.group) {
const groupId = Number(route.query.group)
const groupPlans = checkout.value.plans.filter(p => p.group_id === groupId)
if (groupPlans.length === 1) {
selectedPlan.value = groupPlans[0]
} else if (groupPlans.length > 1) {
renewGroupId.value = groupId
showRenewalModal.value = true
}
}
}
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
finally { loading.value = false } finally { loading.value = false }
// Fetch active subscriptions (uses cache, non-blocking)
subscriptionStore.fetchActiveSubscriptions().catch(() => {})
}) })
</script> </script>