diff --git a/backend/internal/service/payment_refund.go b/backend/internal/service/payment_refund.go
index 68f9c697ee..fd2822cc41 100644
--- a/backend/internal/service/payment_refund.go
+++ b/backend/internal/service/payment_refund.go
@@ -102,6 +102,15 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float
func (s *PaymentService) prepDeduct(ctx context.Context, o *dbent.PaymentOrder, p *RefundPlan, force bool) *RefundResult {
if o.OrderType == payment.OrderTypeSubscription {
p.DeductionType = payment.DeductionTypeSubscription
+ if o.SubscriptionGroupID != nil && o.SubscriptionDays != nil {
+ p.SubDaysToDeduct = *o.SubscriptionDays
+ sub, err := s.subscriptionSvc.GetActiveSubscription(ctx, o.UserID, *o.SubscriptionGroupID)
+ if err == nil && sub != nil {
+ p.SubscriptionID = sub.ID
+ } else if !force {
+ return &RefundResult{Success: false, Warning: "cannot find active subscription for deduction, use force", RequireForce: true}
+ }
+ }
return nil
}
u, err := s.userRepo.GetByID(ctx, o.UserID)
@@ -137,6 +146,22 @@ func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*Ref
p.BalanceToDeduct = 0
}
}
+ if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
+ if !s.hasAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED") {
+ _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, -p.SubDaysToDeduct)
+ if err != nil {
+ // If deducting would expire the subscription, revoke it entirely
+ slog.Info("subscription deduction would expire, revoking", "orderID", p.OrderID, "subID", p.SubscriptionID, "days", p.SubDaysToDeduct)
+ if revokeErr := s.subscriptionSvc.RevokeSubscription(ctx, p.SubscriptionID); revokeErr != nil {
+ s.restoreStatus(ctx, p)
+ return nil, fmt.Errorf("revoke subscription: %w", revokeErr)
+ }
+ }
+ } else {
+ slog.Warn("skipping subscription deduction on retry (previous rollback failed)", "orderID", p.OrderID)
+ p.SubDaysToDeduct = 0
+ }
+ }
if err := s.gwRefund(ctx, p); err != nil {
return s.handleGwFail(ctx, p, err)
}
@@ -204,6 +229,13 @@ func (s *PaymentService) RollbackRefund(ctx context.Context, p *RefundPlan, gErr
return false
}
}
+ if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
+ if _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, p.SubDaysToDeduct); err != nil {
+ slog.Error("[CRITICAL] subscription rollback failed", "orderID", p.OrderID, "subID", p.SubscriptionID, "days", p.SubDaysToDeduct, "error", err)
+ s.writeAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED", "admin", map[string]any{"gatewayError": psErrMsg(gErr), "rollbackError": psErrMsg(err), "subDaysDeducted": p.SubDaysToDeduct})
+ return false
+ }
+ }
return true
}
diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go
index e96e3b7430..7679eb8d54 100644
--- a/backend/internal/service/payment_service.go
+++ b/backend/internal/service/payment_service.go
@@ -109,6 +109,7 @@ type RefundPlan struct {
DeductionType string
BalanceToDeduct float64
SubDaysToDeduct int
+ SubscriptionID int64
}
type RefundResult struct {
diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue
index f4f1122844..087b3dcf5e 100644
--- a/frontend/src/components/payment/PaymentStatusPanel.vue
+++ b/frontend/src/components/payment/PaymentStatusPanel.vue
@@ -70,8 +70,14 @@
{{ scanTitle }}
-
+
+
+
+
+
+
+
{{ scanHint }}
@@ -152,6 +158,18 @@ let countdownTimer: ReturnType
| null = null
const isAlipay = computed(() => props.paymentType.includes('alipay'))
const isWxpay = computed(() => props.paymentType.includes('wxpay'))
+const qrBorderClass = computed(() => {
+ if (isAlipay.value) return 'border-[#00AEEF] bg-blue-50 dark:border-[#00AEEF]/70 dark:bg-blue-950/20'
+ if (isWxpay.value) return 'border-[#2BB741] bg-green-50 dark:border-[#2BB741]/70 dark:bg-green-950/20'
+ return 'border-gray-200 bg-white dark:border-dark-600 dark:bg-dark-800'
+})
+
+const qrLogoBgClass = computed(() => {
+ if (isAlipay.value) return 'bg-[#00AEEF]'
+ if (isWxpay.value) return 'bg-[#2BB741]'
+ return 'bg-gray-400'
+})
+
const scanTitle = computed(() => {
if (isAlipay.value) return t('payment.qr.scanAlipay')
if (isWxpay.value) return t('payment.qr.scanWxpay')
@@ -179,33 +197,10 @@ function reopenPopup() {
async function renderQR() {
await nextTick()
if (!qrCanvas.value || !qrUrl.value) return
- const logoSrc = isAlipay.value ? alipayIcon : isWxpay.value ? wxpayIcon : null
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
width: 220, margin: 2,
- errorCorrectionLevel: logoSrc ? 'H' : 'M',
+ errorCorrectionLevel: 'H',
})
- if (!logoSrc) return
- const canvas = qrCanvas.value
- const ctx = canvas.getContext('2d')
- if (!ctx) return
- const img = new Image()
- img.src = logoSrc
- img.onload = () => {
- const logoSize = 40
- const x = (canvas.width - logoSize) / 2
- const y = (canvas.height - logoSize) / 2
- const pad = 4
- ctx.fillStyle = '#FFFFFF'
- ctx.beginPath()
- const r = 5
- ctx.moveTo(x - pad + r, y - pad)
- ctx.arcTo(x + logoSize + pad, y - pad, x + logoSize + pad, y + logoSize + pad, r)
- ctx.arcTo(x + logoSize + pad, y + logoSize + pad, x - pad, y + logoSize + pad, r)
- ctx.arcTo(x - pad, y + logoSize + pad, x - pad, y - pad, r)
- ctx.arcTo(x - pad, y - pad, x + logoSize + pad, y - pad, r)
- ctx.fill()
- ctx.drawImage(img, x, y, logoSize, logoSize)
- }
}
async function pollStatus() {
diff --git a/frontend/src/components/payment/SubscriptionPlanCard.vue b/frontend/src/components/payment/SubscriptionPlanCard.vue
index e2540dcbd7..fbaa274476 100644
--- a/frontend/src/components/payment/SubscriptionPlanCard.vue
+++ b/frontend/src/components/payment/SubscriptionPlanCard.vue
@@ -88,7 +88,7 @@
:class="['w-full rounded-xl py-2.5 text-sm font-semibold transition-all active:scale-[0.98]', btnClass]"
@click="emit('select', plan)"
>
- {{ t('payment.subscribeNow') }}
+ {{ isRenewal ? t('payment.renewNow') : t('payment.subscribeNow') }}
@@ -98,6 +98,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SubscriptionPlan } from '@/types/payment'
+import type { UserSubscription } from '@/types'
import {
platformAccentBarClass,
platformBadgeLightClass,
@@ -109,11 +110,14 @@ import {
platformLabel,
} from '@/utils/platformColors'
-const props = defineProps<{ plan: SubscriptionPlan }>()
+const props = defineProps<{ plan: SubscriptionPlan; activeSubscriptions?: UserSubscription[] }>()
const emit = defineEmits<{ select: [plan: SubscriptionPlan] }>()
const { t } = useI18n()
const platform = computed(() => props.plan.group_platform || '')
+const isRenewal = computed(() =>
+ props.activeSubscriptions?.some(s => s.group_id === props.plan.group_id && s.status === 'active') ?? false
+)
// Derived color classes from central config
const accentClass = computed(() => platformAccentBarClass(platform.value))
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 5da91dc484..31db41ba2d 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -5172,6 +5172,7 @@ export default {
qrFailed: 'Failed to get WeChat Pay QR code',
},
subscribeNow: 'Subscribe Now',
+ renewNow: 'Renew',
planFeatures: 'Features',
planCard: {
rate: 'Rate',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index c8d4ed1946..8f071afbdc 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -5369,6 +5369,7 @@ export default {
qrFailed: '未能获取微信支付二维码',
},
subscribeNow: '立即开通',
+ renewNow: '续费',
planFeatures: '功能特性',
planCard: {
rate: '倍率',
diff --git a/frontend/src/style.css b/frontend/src/style.css
index f9b64f781c..4ab18cf5c7 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -148,6 +148,20 @@
@apply dark:hover:bg-[#635bff];
}
+ .btn-alipay {
+ @apply bg-[#00AEEF] text-white shadow-md shadow-[#00AEEF]/25;
+ @apply hover:bg-[#009dd6] hover:shadow-lg hover:shadow-[#00AEEF]/30;
+ @apply active:bg-[#008cbe];
+ @apply dark:shadow-[#00AEEF]/20;
+ }
+
+ .btn-wxpay {
+ @apply bg-[#2BB741] text-white shadow-md shadow-[#2BB741]/25;
+ @apply hover:bg-[#24a038] hover:shadow-lg hover:shadow-[#2BB741]/30;
+ @apply active:bg-[#1d8a2f];
+ @apply dark:shadow-[#2BB741]/20;
+ }
+
.btn-sm {
@apply rounded-lg px-3 py-1.5 text-xs;
}
diff --git a/frontend/src/utils/platformColors.ts b/frontend/src/utils/platformColors.ts
index 79c6482bd2..2db64d427b 100644
--- a/frontend/src/utils/platformColors.ts
+++ b/frontend/src/utils/platformColors.ts
@@ -78,6 +78,32 @@ const DISCOUNT: Record = {
}
const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
+// ── Header gradient (subscription confirm) ─────────────────────────
+const GRADIENT: Record = {
+ anthropic: 'from-orange-500 to-orange-600',
+ openai: 'from-emerald-500 to-emerald-600',
+ antigravity: 'from-purple-500 to-purple-600',
+ gemini: 'from-blue-500 to-blue-600',
+}
+const GRADIENT_DEFAULT = 'from-primary-500 to-primary-600'
+
+// ── Header text (light text on gradient bg) ────────────────────────
+const GRADIENT_TEXT: Record = {
+ anthropic: 'text-orange-100',
+ openai: 'text-emerald-100',
+ antigravity: 'text-purple-100',
+ gemini: 'text-blue-100',
+}
+const GRADIENT_TEXT_DEFAULT = 'text-primary-100'
+
+const GRADIENT_SUBTEXT: Record = {
+ anthropic: 'text-orange-200',
+ openai: 'text-emerald-200',
+ antigravity: 'text-purple-200',
+ gemini: 'text-blue-200',
+}
+const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200'
+
// ── Public API ──────────────────────────────────────────────────────
function isPlatform(p: string): p is Platform {
@@ -116,6 +142,18 @@ export function platformDiscountClass(p: string): string {
return isPlatform(p) ? DISCOUNT[p] : DISCOUNT_DEFAULT
}
+export function platformGradientClass(p: string): string {
+ return isPlatform(p) ? GRADIENT[p] : GRADIENT_DEFAULT
+}
+
+export function platformGradientTextClass(p: string): string {
+ return isPlatform(p) ? GRADIENT_TEXT[p] : GRADIENT_TEXT_DEFAULT
+}
+
+export function platformGradientSubtextClass(p: string): string {
+ return isPlatform(p) ? GRADIENT_SUBTEXT[p] : GRADIENT_SUBTEXT_DEFAULT
+}
+
export function platformLabel(p: string): string {
switch (p) {
case 'anthropic': return 'Anthropic'
diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue
index e2dbffd0d8..360877cf03 100644
--- a/frontend/src/views/user/PaymentView.vue
+++ b/frontend/src/views/user/PaymentView.vue
@@ -83,7 +83,7 @@
-