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:
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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">¥{{ 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">¥{{ user.amount.toFixed(2) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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">¥{{ 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">¥{{ 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user