update pay function

This commit is contained in:
2025-11-20 07:46:50 +08:00
parent f515dc94f4
commit 4a1157c0b6
3 changed files with 285 additions and 48 deletions

76
app.py
View File

@@ -1323,40 +1323,96 @@ def calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_c
if price <= 0: if price <= 0:
return {'error': f'{to_cycle} 周期价格未配置'} return {'error': f'{to_cycle} 周期价格未配置'}
# 4. 判断是新购还是续费 # 4. 判断订阅类型和计算价格
is_renewal = False is_renewal = False
is_upgrade = False
is_downgrade = False
subscription_type = 'new' subscription_type = 'new'
current_plan = None current_plan = None
current_cycle = None current_cycle = None
remaining_value = 0
final_price = price
if current_sub and current_sub.subscription_type in ['pro', 'max']: if current_sub and current_sub.subscription_type in ['pro', 'max']:
# 如果当前是付费用户,则为续费
is_renewal = True
subscription_type = 'renew'
current_plan = current_sub.subscription_type current_plan = current_sub.subscription_type
current_cycle = current_sub.billing_cycle current_cycle = current_sub.billing_cycle
# 5. 构建结果(续费和新购价格完全一致) if current_plan == to_plan_name:
# 同级续费:延长时长,全价购买
is_renewal = True
subscription_type = 'renew'
elif current_plan == 'pro' and to_plan_name == 'max':
# 升级Pro → Max需要计算差价
is_upgrade = True
subscription_type = 'upgrade'
# 计算当前订阅的剩余价值
if current_sub.end_date and current_sub.end_date > datetime.utcnow():
# 获取当前套餐的原始价格
current_plan_obj = SubscriptionPlan.query.filter_by(name=current_plan, is_active=True).first()
if current_plan_obj and current_plan_obj.pricing_options:
try:
pricing_opts = json.loads(current_plan_obj.pricing_options)
current_price = None
for opt in pricing_opts:
if opt.get('cycle_key') == current_cycle:
current_price = float(opt.get('price', 0))
break
if current_price and current_price > 0:
# 计算剩余天数
remaining_days = (current_sub.end_date - datetime.utcnow()).days
# 计算总天数
cycle_days_map = {
'monthly': 30,
'quarterly': 90,
'semiannual': 180,
'yearly': 365
}
total_days = cycle_days_map.get(current_cycle, 30)
# 计算剩余价值
if total_days > 0 and remaining_days > 0:
remaining_value = current_price * (remaining_days / total_days)
# 实付金额 = 新套餐价格 - 剩余价值
final_price = max(0, price - remaining_value)
except:
pass
elif current_plan == 'max' and to_plan_name == 'pro':
# 降级Max → Pro到期后切换全价购买
is_downgrade = True
subscription_type = 'downgrade'
else:
# 其他情况视为新购
subscription_type = 'new'
# 5. 构建结果
result = { result = {
'is_renewal': is_renewal, 'is_renewal': is_renewal,
'is_upgrade': is_upgrade,
'is_downgrade': is_downgrade,
'subscription_type': subscription_type, 'subscription_type': subscription_type,
'current_plan': current_plan, 'current_plan': current_plan,
'current_cycle': current_cycle, 'current_cycle': current_cycle,
'new_plan_price': price, 'new_plan_price': price,
'original_price': price, # 新套餐原价
'remaining_value': remaining_value, # 当前订阅剩余价值(仅升级时有效)
'original_amount': price, 'original_amount': price,
'discount_amount': 0, 'discount_amount': 0,
'final_amount': price, 'final_amount': final_price,
'promo_code': None, 'promo_code': None,
'promo_error': None 'promo_error': None
} }
# 6. 应用优惠码 # 6. 应用优惠码(基于差价后的金额)
if promo_code and promo_code.strip(): if promo_code and promo_code.strip():
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, price, user_id) # 优惠码作用于差价后的金额
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, final_price, user_id)
if promo: if promo:
discount = calculate_discount(promo, price) discount = calculate_discount(promo, final_price)
result['discount_amount'] = float(discount) result['discount_amount'] = float(discount)
result['final_amount'] = price - float(discount) result['final_amount'] = final_price - float(discount)
result['promo_code'] = promo.code result['promo_code'] = promo.code
elif error: elif error:
result['promo_error'] = error result['promo_error'] = error

View File

@@ -51,7 +51,7 @@ export default function SubscriptionContentNew() {
}, },
}); });
const [selectedCycle, setSelectedCycle] = useState('yearly'); const [selectedCycle, setSelectedCycle] = useState('monthly');
const [selectedPlan, setSelectedPlan] = useState(null); const [selectedPlan, setSelectedPlan] = useState(null);
const [subscriptionPlans, setSubscriptionPlans] = useState([]); const [subscriptionPlans, setSubscriptionPlans] = useState([]);
const [priceInfo, setPriceInfo] = useState(null); const [priceInfo, setPriceInfo] = useState(null);
@@ -491,6 +491,37 @@ export default function SubscriptionContentNew() {
return icons[iconName] || FaStar; return icons[iconName] || FaStar;
}; };
// 获取按钮文字
const getButtonText = (plan: any) => {
const currentPlanName = user?.subscription_type;
const isActive = user?.subscription_status === 'active';
if (!isActive || !currentPlanName || currentPlanName === 'free') {
return `选择${plan.displayName}`;
}
if (currentPlanName === plan.name) {
// 同级续费
return `续费${plan.displayName}`;
}
// 升级或降级
if (currentPlanName === 'pro' && plan.name === 'max') {
return `升级为${plan.displayName}`;
}
if (currentPlanName === 'max' && plan.name === 'pro') {
return `到期后切换到${plan.displayName}`;
}
return `选择${plan.displayName}`;
};
// 判断按钮是否可点击
const isButtonDisabled = (plan: any) => {
return false; // 所有套餐都可以选择,包括当前套餐(续费)
};
return ( return (
<Box <Box
minH="100vh" minH="100vh"
@@ -562,6 +593,115 @@ export default function SubscriptionContentNew() {
</motion.div> </motion.div>
</VStack> </VStack>
{/* 当前订阅状态 */}
{user && user.subscription_type && user.subscription_type !== 'free' && user.subscription_status === 'active' && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Box
mb={12}
p={6}
borderRadius="2xl"
bg="rgba(212, 175, 55, 0.05)"
border="2px solid"
borderColor="rgba(212, 175, 55, 0.3)"
backdropFilter="blur(20px)"
maxW="600px"
mx="auto"
position="relative"
overflow="hidden"
_before={{
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '4px',
bgGradient: 'linear-gradient(90deg, rgba(212, 175, 55, 0.5), rgba(212, 175, 55, 1), rgba(212, 175, 55, 0.5))',
}}
>
<VStack spacing={4} align="stretch">
<HStack justify="space-between" align="center">
<HStack spacing={3}>
<Icon
as={user.subscription_type === 'max' ? FaCrown : FaGem}
color="#D4AF37"
boxSize={6}
/>
<VStack align="start" spacing={0}>
<Text color="white" fontSize="lg" fontWeight="bold">
: {user.subscription_type === 'max' ? 'Max 旗舰版' : 'Pro 专业版'}
</Text>
<Text color="rgba(255, 255, 255, 0.6)" fontSize="xs">
{user.billing_cycle === 'monthly' ? '月付' :
user.billing_cycle === 'quarterly' ? '季付' :
user.billing_cycle === 'semiannual' ? '半年付' : '年付'}
</Text>
</VStack>
</HStack>
<Badge
px={3}
py={1}
borderRadius="full"
bg="rgba(76, 175, 80, 0.2)"
border="1px solid rgba(76, 175, 80, 0.4)"
color="green.300"
fontSize="xs"
fontWeight="medium"
>
使
</Badge>
</HStack>
<Divider borderColor="rgba(212, 175, 55, 0.2)" />
<Flex justify="space-between" align="center">
<HStack spacing={2}>
<Icon as={FaClock} color="rgba(212, 175, 55, 0.8)" boxSize={4} />
<Text color="rgba(255, 255, 255, 0.7)" fontSize="sm">
</Text>
</HStack>
<Text color="#D4AF37" fontSize="md" fontWeight="bold">
{user.end_date
? new Date(user.end_date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
: '永久有效'
}
</Text>
</Flex>
{user.end_date && (() => {
const endDate = new Date(user.end_date);
const today = new Date();
const daysLeft = Math.ceil((endDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (daysLeft > 0 && daysLeft <= 30) {
return (
<Box
p={2}
borderRadius="md"
bg="rgba(255, 165, 0, 0.1)"
border="1px solid rgba(255, 165, 0, 0.3)"
>
<Text color="orange.300" fontSize="xs" textAlign="center">
{daysLeft}
</Text>
</Box>
);
}
return null;
})()}
</VStack>
</Box>
</motion.div>
)}
{/* 计费周期选择器 */} {/* 计费周期选择器 */}
<VStack spacing={6} mb={16}> <VStack spacing={6} mb={16}>
<Text fontSize="md" color="rgba(255, 255, 255, 0.7)"> <Text fontSize="md" color="rgba(255, 255, 255, 0.7)">
@@ -791,55 +931,41 @@ export default function SubscriptionContentNew() {
size="lg" size="lg"
h="56px" h="56px"
bg={ bg={
isCurrentPlan isPremium
? 'transparent'
: isPremium
? 'linear-gradient(135deg, #D4AF37 0%, #B8941F 100%)' ? 'linear-gradient(135deg, #D4AF37 0%, #B8941F 100%)'
: 'rgba(255, 255, 255, 0.05)' : 'rgba(255, 255, 255, 0.05)'
} }
color={ color={
isCurrentPlan isPremium
? 'rgba(255, 255, 255, 0.5)'
: isPremium
? '#000' ? '#000'
: '#fff' : '#fff'
} }
border={ border={
isCurrentPlan isPremium
? '1px solid rgba(255, 255, 255, 0.2)'
: isPremium
? 'none' ? 'none'
: '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(255, 255, 255, 0.1)'
} }
fontWeight="bold" fontWeight="bold"
fontSize="md" fontSize="md"
borderRadius="lg" borderRadius="lg"
onClick={() => !isCurrentPlan && handleSubscribe(plan)} onClick={() => handleSubscribe(plan)}
isDisabled={isCurrentPlan} isDisabled={isButtonDisabled(plan)}
cursor={isCurrentPlan ? 'not-allowed' : 'pointer'} cursor="pointer"
_hover={ _hover={{
!isCurrentPlan transform: 'translateY(-2px)',
? { shadow: isPremium
transform: 'translateY(-2px)', ? '0 8px 30px rgba(212, 175, 55, 0.4)'
shadow: isPremium : '0 8px 20px rgba(255, 255, 255, 0.1)',
? '0 8px 30px rgba(212, 175, 55, 0.4)' bg: isPremium
: '0 8px 20px rgba(255, 255, 255, 0.1)', ? 'linear-gradient(135deg, #E5C047 0%, #C9A52F 100%)'
bg: isPremium : 'rgba(255, 255, 255, 0.08)',
? 'linear-gradient(135deg, #E5C047 0%, #C9A52F 100%)' }}
: 'rgba(255, 255, 255, 0.08)', _active={{
} transform: 'translateY(0)',
: {} }}
}
_active={
!isCurrentPlan
? {
transform: 'translateY(0)',
}
: {}
}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)" transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
> >
{isCurrentPlan ? '当前套餐' : `选择${plan.displayName}`} {getButtonText(plan)}
</Button> </Button>
</Box> </Box>
@@ -980,6 +1106,57 @@ export default function SubscriptionContentNew() {
<ModalBody pb={6}> <ModalBody pb={6}>
{!paymentOrder ? ( {!paymentOrder ? (
<VStack spacing={6} align="stretch"> <VStack spacing={6} align="stretch">
{/* 订阅类型提示 */}
{selectedPlan && priceInfo && (
<>
{priceInfo.is_upgrade && (
<Box
p={3}
bg="rgba(76, 175, 80, 0.1)"
borderRadius="lg"
border="1px solid rgba(76, 175, 80, 0.3)"
>
<HStack spacing={2}>
<Icon as={FaCheck} color="green.400" />
<Text color="green.400" fontSize="sm" fontWeight="medium">
{selectedPlan.displayName}
</Text>
</HStack>
</Box>
)}
{priceInfo.is_downgrade && (
<Box
p={3}
bg="rgba(255, 165, 0, 0.1)"
borderRadius="lg"
border="1px solid rgba(255, 165, 0, 0.3)"
>
<HStack spacing={2}>
<Icon as={FaClock} color="orange.400" />
<Text color="orange.400" fontSize="sm" fontWeight="medium">
{priceInfo.current_plan?.toUpperCase()}{selectedPlan.displayName}
</Text>
</HStack>
</Box>
)}
{priceInfo.is_renewal && (
<Box
p={3}
bg="rgba(33, 150, 243, 0.1)"
borderRadius="lg"
border="1px solid rgba(33, 150, 243, 0.3)"
>
<HStack spacing={2}>
<Icon as={FaRedo} color="blue.400" />
<Text color="blue.400" fontSize="sm" fontWeight="medium">
{selectedPlan.displayName}
</Text>
</HStack>
</Box>
)}
</>
)}
{/* 价格明细 */} {/* 价格明细 */}
{selectedPlan && priceInfo && ( {selectedPlan && priceInfo && (
<Box <Box

View File

@@ -158,12 +158,16 @@ export const subscriptionConfig = {
answer: '我们目前支持微信支付。扫描支付二维码后,系统会自动检测支付状态并激活您的订阅。支付过程安全可靠,所有交易都经过加密处理。', answer: '我们目前支持微信支付。扫描支付二维码后,系统会自动检测支付状态并激活您的订阅。支付过程安全可靠,所有交易都经过加密处理。',
}, },
{ {
question: '升级或切换套餐时,原套餐的费用怎么办', question: '同级续费如何计费',
answer: '当您升级套餐或切换计费周期时,系统会自动计算您当前订阅的剩余价值并用于抵扣新套餐的费用。\n\n计算方式\n• 剩余价值 = 原套餐价格 × (剩余天数 / 总天数)\n• 实付金额 = 新套餐价格 - 剩余价值 - 优惠码折扣\n\n例如您购买了年付Pro版¥2699,使用了180天后升级到Max版¥5399/年),剩余价值约¥1350将自动抵扣,实付约¥4049。', answer: '如果您是Pro用户续费Pro版本或Max用户续费Max版本支付后将在当前订阅到期日基础上延长相应时长。例如您的Max年付版本还有30天到期续费Max年付后新的到期时间将延长至395天后30天+365天。',
}, },
{ {
question: '可以在不同计费周期之间切换吗', question: 'Pro用户如何升级到Max',
answer: '可以。您可以随时更改计费周期。如果从短期切换到长期,系统会计算剩余价值并应用到新的订阅中。长期套餐(季付、半年付、年付)可享受更大的折扣优惠。', answer: '从Pro升级到Max需要补差价升级后立即生效。系统会根据您Pro订阅的剩余价值计算需要补缴的费用。支付成功后您将立即获得Max版本的所有功能。',
},
{
question: 'Max用户可以切换到Pro吗',
answer: '可以。Max用户购买Pro套餐后系统会在当前Max订阅到期后自动切换到Pro版本并从到期日开始计算Pro的订阅时长。在Max到期前您仍可继续使用Max的全部功能。',
}, },
{ {
question: '是否支持退款?', question: '是否支持退款?',