rentease-backend-new/services/billingService.js

274 lines
7.0 KiB
JavaScript

const { Tenant, SubscriptionPlan, TenantSubscription, Apartment, Room, User } = require('../models');
const { Op } = require('sequelize');
const { logOperation } = require('../utils/logger');
const addCalendarMonths = (date, months) => {
const source = new Date(date);
const day = source.getDate();
const target = new Date(source);
target.setDate(1);
target.setMonth(target.getMonth() + Number(months));
const lastDayOfTargetMonth = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
target.setDate(Math.min(day, lastDayOfTargetMonth));
return target;
};
const getBillingCycle = (months) => {
if (Number(months) === 12) return 'yearly';
if (Number(months) === 1) return 'monthly';
return 'custom';
};
const normalizeAmount = (value) => Number.parseFloat(Number(value || 0).toFixed(2));
const checkTenantExpiration = async () => {
const now = new Date();
const trialExpiredTenants = await Tenant.findAll({
where: {
billingStatus: 'trial_active',
trialEndDate: { [Op.lt]: now }
}
});
for (const tenant of trialExpiredTenants) {
await tenant.update({ billingStatus: 'trial_expired' });
await logOperation({
tenantId: tenant.id,
module: '计费管理',
action: '试用期到期',
description: `租户 ${tenant.code} 试用期已过期`,
status: 'success'
});
}
const paidExpiredTenants = await Tenant.findAll({
where: {
billingStatus: 'paid_active',
paidEndDate: { [Op.lt]: now }
}
});
for (const tenant of paidExpiredTenants) {
await tenant.update({ billingStatus: 'paid_expired' });
await TenantSubscription.update(
{ status: 'expired' },
{
where: {
tenantId: tenant.id,
status: 'active',
endDate: { [Op.lt]: now }
}
}
);
await logOperation({
tenantId: tenant.id,
module: '计费管理',
action: '付费期到期',
description: `租户 ${tenant.code} 付费期已过期`,
status: 'success'
});
}
return {
trialExpired: trialExpiredTenants.length,
paidExpired: paidExpiredTenants.length,
longExpired: 0
};
};
const getUpcomingExpiredTenants = async (days = 7) => {
const reminderDate = new Date();
reminderDate.setDate(reminderDate.getDate() + days);
return Tenant.findAll({
where: {
[Op.or]: [
{
billingStatus: 'trial_active',
trialEndDate: {
[Op.lte]: reminderDate,
[Op.gte]: new Date()
}
},
{
billingStatus: 'paid_active',
paidEndDate: {
[Op.lte]: reminderDate,
[Op.gte]: new Date()
}
}
]
},
include: [{
model: SubscriptionPlan,
as: 'subscriptionPlan'
}]
});
};
const calculateOverage = async (tenantId) => {
const tenant = await Tenant.findByPk(tenantId, {
include: [{
model: SubscriptionPlan,
as: 'subscriptionPlan'
}]
});
if (!tenant) {
throw new Error('租户不存在');
}
const [apartmentCount, roomCount, userCount] = await Promise.all([
Apartment.count({ where: { tenantId, isDeleted: 0 } }),
Room.count({ where: { tenantId, isDeleted: 0 } }),
User.count({ where: { tenantId, isDeleted: 0 } })
]);
return {
usage: {
apartments: apartmentCount,
rooms: roomCount,
users: userCount
},
limits: {
apartments: tenant.maxApartments,
rooms: tenant.maxRooms,
users: tenant.maxUsers
},
overage: {
apartments: Math.max(0, apartmentCount - tenant.maxApartments),
rooms: Math.max(0, roomCount - tenant.maxRooms),
users: Math.max(0, userCount - tenant.maxUsers)
}
};
};
const calculateRenewalAmount = async (tenantId, planId, months) => {
const plan = await SubscriptionPlan.findOne({
where: { id: planId, status: 'active', isDeleted: 0 }
});
if (!plan) {
throw new Error('套餐不存在或已停用');
}
const normalizedMonths = Number.parseInt(months, 10);
if (!Number.isInteger(normalizedMonths) || normalizedMonths < 1 || normalizedMonths > 36) {
throw new Error('购买月数必须在1-36之间');
}
const billingCycle = getBillingCycle(normalizedMonths);
const monthlyPrice = Number(plan.monthlyPrice || 0);
const yearlyPrice = Number(plan.yearlyPrice || 0);
const baseAmount = billingCycle === 'yearly' && yearlyPrice > 0
? yearlyPrice
: monthlyPrice * normalizedMonths;
return {
baseAmount: normalizeAmount(baseAmount),
overageAmount: 0,
discountAmount: 0,
totalAmount: normalizeAmount(baseAmount),
billingCycle,
details: {
planName: plan.name,
monthlyPrice: normalizeAmount(monthlyPrice),
yearlyPrice: normalizeAmount(yearlyPrice),
months: normalizedMonths,
billingCycle
}
};
};
const processPaymentSuccess = async ({ order, payment, operatorId, transaction }) => {
const tenant = await Tenant.findByPk(order.tenantId, { transaction });
if (!tenant) {
throw new Error('租户不存在');
}
const plan = await SubscriptionPlan.findByPk(order.planId, { transaction });
if (!plan) {
throw new Error('套餐不存在');
}
const now = new Date();
const currentEnd = tenant.paidEndDate ? new Date(tenant.paidEndDate) : null;
const startDate = tenant.billingStatus === 'paid_active' && currentEnd && currentEnd > now
? currentEnd
: now;
const endDate = addCalendarMonths(startDate, order.months);
const billingCycle = order.billingCycle || getBillingCycle(order.months);
await TenantSubscription.update(
{ status: 'expired' },
{
where: {
tenantId: tenant.id,
status: 'active',
isDeleted: 0
},
transaction
}
);
const subscription = await TenantSubscription.create({
tenantId: tenant.id,
planId: plan.id,
orderId: order.id,
status: 'active',
billingCycle,
months: order.months,
startDate,
endDate,
amount: order.actualAmount,
createBy: operatorId,
updateBy: operatorId
}, { transaction });
await tenant.update({
planId: plan.id,
billingStatus: 'paid_active',
paidStartDate: startDate,
paidEndDate: endDate,
currentPeriodStart: startDate,
currentPeriodEnd: endDate,
maxApartments: plan.maxApartments,
maxRooms: plan.maxRooms,
maxUsers: plan.maxUsers,
updateBy: operatorId
}, { transaction });
await order.update({
subscriptionId: subscription.id,
periodStart: startDate,
periodEnd: endDate
}, { transaction });
await logOperation({
tenantId: tenant.id,
userId: operatorId,
module: '计费管理',
action: '订阅生效',
description: `租户订阅 ${plan.name} ${order.months} 个月,有效期至 ${endDate.toLocaleDateString()}`,
status: 'success'
});
return {
subscription,
billingStatus: 'paid_active',
paidStartDate: startDate,
paidEndDate: endDate,
paymentId: payment ? payment.id : null
};
};
module.exports = {
checkTenantExpiration,
getUpcomingExpiredTenants,
calculateOverage,
calculateRenewalAmount,
processPaymentSuccess
};