fix: 按实际纯币结算金额计算保险费用

This commit is contained in:
yml2213
2026-11-05 19:19:12 +08:00
parent 53574236ef
commit b884d01855
14 changed files with 184 additions and 25 deletions
+1 -1
View File
@@ -406,7 +406,7 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
// 可完结时租客实际承担的租金向金额;有 shortfall 时仍展示完整应付便于协商 // 可完结时租客实际承担的租金向金额;有 shortfall 时仍展示完整应付便于协商
actualRentAmountCent := actualBuyerRentCent actualRentAmountCent := actualBuyerRentCent
ownerRentIncomeCent := actualOwnerRentCent ownerRentIncomeCent := actualOwnerRentCent
insuranceFeeCent := calculateInsuranceFeeCent(ownerRentIncomeCent, order.InsuranceFeeBps) insuranceFeeCent := calculateInsuranceFeeCent(usedOwnerCoinPriceCent, order.InsuranceFeeBps)
ownerRentIncomeCent = maxCent(ownerRentIncomeCent-insuranceFeeCent, 0) ownerRentIncomeCent = maxCent(ownerRentIncomeCent-insuranceFeeCent, 0)
platformFeeCent := maxCent(actualRentAmountCent-ownerRentIncomeCent, 0) platformFeeCent := maxCent(actualRentAmountCent-ownerRentIncomeCent, 0)
depositCompensationCent := damageAppliedCent depositCompensationCent := damageAppliedCent
@@ -49,13 +49,48 @@ func TestCalculateCheckoutSettlementDeductsPremiumInsuranceFee(t *testing.T) {
} }
settlement := calculateCheckoutSettlement(order, 12000, 100, 0) settlement := calculateCheckoutSettlement(order, 12000, 100, 0)
if settlement.InsuranceFeeBps != 400 || settlement.InsuranceFeeCent != 1412 { if settlement.InsuranceFeeBps != 400 || settlement.InsuranceFeeCent != 932 {
t.Fatalf("保险费用快照不正确: bps=%d fee=%d", settlement.InsuranceFeeBps, settlement.InsuranceFeeCent) t.Fatalf("保险费用快照不正确: bps=%d fee=%d", settlement.InsuranceFeeBps, settlement.InsuranceFeeCent)
} }
if settlement.OwnerRentIncomeCent != 33888 { if settlement.OwnerRentIncomeCent != 34368 {
t.Fatalf("号主净租金 = %d, want 33888", settlement.OwnerRentIncomeCent) t.Fatalf("号主净租金 = %d, want 34368", settlement.OwnerRentIncomeCent)
} }
if settlement.PlatformFeeCent != 4412 { if settlement.PlatformFeeCent != 3932 {
t.Fatalf("平台收入 = %d, want 4412", settlement.PlatformFeeCent) t.Fatalf("平台收入 = %d, want 3932", settlement.PlatformFeeCent)
}
}
func TestCalculateCheckoutSettlementUsesActualPureCoinAsInsuranceBase(t *testing.T) {
order := model.RentalOrder{
RentAmountCent: 25000,
OwnerRentAmountCent: 25000,
DepositAmountCent: 15000,
InsuranceFeeBps: 800,
AccountSnapshot: datatypes.JSON([]byte(`{
"haf_coin_amount": 100000000,
"asset_summary": {
"price_breakdown": {
"buyer_ratio": 40,
"seller_ratio": 40,
"buyer_coin_base_price": 250,
"seller_coin_base_price": 250,
"consumable_price": 0
}
}
}`)),
}
settlement := calculateCheckoutSettlement(order, 5000, 50, 0)
if settlement.PureCoinAmountCent != 12500 {
t.Fatalf("实际纯币原价 = %d, want 12500", settlement.PureCoinAmountCent)
}
if settlement.InsuranceFeeCent != 1000 {
t.Fatalf("保险费用 = %d, want 1000", settlement.InsuranceFeeCent)
}
if settlement.ExtraItemAmountCent != 5000 {
t.Fatalf("额外物品 = %d, want 5000", settlement.ExtraItemAmountCent)
}
if settlement.OwnerRentIncomeCent != 16500 {
t.Fatalf("号主净租金 = %d, want 16500", settlement.OwnerRentIncomeCent)
} }
} }
@@ -9,6 +9,21 @@ import (
const defaultBannerHotspotHoverScale = 1.06 const defaultBannerHotspotHoverScale = 1.06
const maxBannerHotspotHoverScale = 1.5 const maxBannerHotspotHoverScale = 1.5
func migrateInsuranceBillingCopy(value string) string {
value = strings.Replace(
value,
"注:选择此方案,需额外抽取订单佣金的8.0%作为服务费:押金须达到推荐押金方可购买。",
"无忧保险服务费按实际纯币结算金额计算,额外物品不计入计费基数;具体费率以当前配置为准。押金须达到推荐押金方可购买。",
1,
)
return strings.Replace(
value,
"并从卖家实际到手租金中扣除。",
"并按实际纯币结算金额计算,额外物品不计入保险基数。",
1,
)
}
func normalizeAnnouncements(items []string) []string { func normalizeAnnouncements(items []string) []string {
normalized := make([]string, 0, len(items)) normalized := make([]string, 0, len(items))
for _, item := range items { for _, item := range items {
@@ -236,9 +251,15 @@ func normalizePublishOptions(options *PublishOptionsDTO) {
} }
if options.PriceConfig.PremiumZoneTooltip == "" { if options.PriceConfig.PremiumZoneTooltip == "" {
options.PriceConfig.PremiumZoneTooltip = defaults.PriceConfig.PremiumZoneTooltip options.PriceConfig.PremiumZoneTooltip = defaults.PriceConfig.PremiumZoneTooltip
} else {
options.PriceConfig.PremiumZoneTooltip =
migrateInsuranceBillingCopy(options.PriceConfig.PremiumZoneTooltip)
} }
if options.PriceConfig.CompensationNote == "" { if options.PriceConfig.CompensationNote == "" {
options.PriceConfig.CompensationNote = defaults.PriceConfig.CompensationNote options.PriceConfig.CompensationNote = defaults.PriceConfig.CompensationNote
} else {
options.PriceConfig.CompensationNote =
migrateInsuranceBillingCopy(options.PriceConfig.CompensationNote)
} }
if options.PriceConfig.PricePlaceholder == "" { if options.PriceConfig.PricePlaceholder == "" {
options.PriceConfig.PricePlaceholder = defaults.PriceConfig.PricePlaceholder options.PriceConfig.PricePlaceholder = defaults.PriceConfig.PricePlaceholder
@@ -120,9 +120,9 @@ func DefaultPublishOptions() PublishOptionsDTO {
1. 发布条件:仅已认证且有效的优质号主可以开启;未开通时需先缴纳平台要求的信用保证金并完成优质号主认证。 1. 发布条件:仅已认证且有效的优质号主可以开启;未开通时需先缴纳平台要求的信用保证金并完成优质号主认证。
2. 专区展示:商品审核通过后将在首页“极品专区”展示,获得更聚焦的高端账号曝光。 2. 专区展示:商品审核通过后将在首页“极品专区”展示,获得更聚焦的高端账号曝光。
3. 价格规则:系统会按后台配置在基础比例上计算极品专区专享比例,审核页展示的最终价格通过后不再改价。 3. 价格规则:系统会按后台配置在基础比例上计算极品专区专享比例,审核页展示的最终价格通过后不再改价。
4. 保险服务:可选择无忧租号保险,服务费率按优质号主及极品专区的当前配置执行,并从卖家实际到手租金中扣除。 4. 保险服务:可选择无忧租号保险,服务费率按优质号主及极品专区的当前配置执行,并按实际纯币结算金额计算,额外物品不计入保险基数。
5. 责任说明:开启专区不会免除商品审核、资料真实性及发布协议中的责任,请如实填写账号资产与出租条件。`, 5. 责任说明:开启专区不会免除商品审核、资料真实性及发布协议中的责任,请如实填写账号资产与出租条件。`,
CompensationNote: "普通赔付方案如下:账号在租期内或祖期外出现封禁,追回、洗号等情况,最高可获赔全款相金+押金\n\n无忧保险赔付方案:按账号市场价值进行赔付(可参照螃蟹,盼之等主流交易平台)。注:选择此方案,需额外抽取订单佣金的8.0%作为服务费:押金须达到推荐押金方可购买。", CompensationNote: "普通赔付方案如下:账号在租期内或祖期外出现封禁,追回、洗号等情况,最高可获赔全款相金+押金\n\n无忧保险赔付方案:按账号市场价值进行赔付(可参照螃蟹,盼之等主流交易平台)。无忧保险服务费按实际纯币结算金额计算,额外物品不计入计费基数;具体费率以当前配置为准。押金须达到推荐押金方可购买。",
PricePlaceholder: "填写币数、保险、体力和负重后自动计算;发布总价=纯币基础价+额外消耗品", PricePlaceholder: "填写币数、保险、体力和负重后自动计算;发布总价=纯币基础价+额外消耗品",
RatioDescription: "1:xx 表示 1 元人民币(RMB)等价兑换 xx 万哈夫币", RatioDescription: "1:xx 表示 1 元人民币(RMB)等价兑换 xx 万哈夫币",
}, },
@@ -178,6 +178,33 @@ func TestNormalizePublishOptionsValueDefaultsPremiumZoneTooltip(t *testing.T) {
} }
} }
func TestNormalizePublishOptionsValueMigratesInsuranceBillingBaseCopy(t *testing.T) {
options := DefaultPublishOptions()
options.PriceConfig.CompensationNote = "注:选择此方案,需额外抽取订单佣金的8.0%作为服务费:押金须达到推荐押金方可购买。"
options.PriceConfig.PremiumZoneTooltip = "4. 保险服务:服务费率按当前配置执行,并从卖家实际到手租金中扣除。"
raw, err := json.Marshal(options)
if err != nil {
t.Fatalf("构造配置失败: %v", err)
}
normalized, err := normalizePublishOptionsValue(string(raw))
if err != nil {
t.Fatalf("规范化失败: %v", err)
}
var got PublishOptionsDTO
if err := json.Unmarshal([]byte(normalized), &got); err != nil {
t.Fatalf("解析规范化结果失败: %v", err)
}
if !strings.Contains(got.PriceConfig.CompensationNote, "按实际纯币结算金额计算") {
t.Fatalf("赔付说明未迁移纯币计费口径: %q", got.PriceConfig.CompensationNote)
}
if strings.Contains(got.PriceConfig.CompensationNote, "订单佣金") {
t.Fatalf("赔付说明仍含旧计费口径: %q", got.PriceConfig.CompensationNote)
}
if !strings.Contains(got.PriceConfig.PremiumZoneTooltip, "额外物品不计入保险基数") {
t.Fatalf("专区说明未迁移纯币计费口径: %q", got.PriceConfig.PremiumZoneTooltip)
}
}
func TestNormalizePublishOptionsValuePreservesExplicitZeroPremiumReviewRatioMarkup(t *testing.T) { func TestNormalizePublishOptionsValuePreservesExplicitZeroPremiumReviewRatioMarkup(t *testing.T) {
options := DefaultPublishOptions() options := DefaultPublishOptions()
options.RatioConfig.PremiumReviewRatioMarkup = 0 options.RatioConfig.PremiumReviewRatioMarkup = 0
@@ -156,7 +156,7 @@ async function save() {
<el-alert <el-alert
type="info" type="info"
:closable="false" :closable="false"
title="费率按号主实际到手租金计算,并在订单结算时扣减;历史订单保留下单时费率快照。协议正文支持 Markdown 表格。" title="费率按实际纯币结算金额计算,额外物品不计入基数,并在订单结算时扣减;历史订单保留下单时费率快照。协议正文支持 Markdown 表格。"
/> />
<template #footer> <template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button> <el-button @click="emit('update:modelValue', false)">取消</el-button>
@@ -154,7 +154,7 @@ const defaultPremiumZoneTooltip = `极品专区是面向优质号主和高价值
1. 发布条件:仅已认证且有效的优质号主可以开启;未开通时需先缴纳平台要求的信用保证金并完成优质号主认证。 1. 发布条件:仅已认证且有效的优质号主可以开启;未开通时需先缴纳平台要求的信用保证金并完成优质号主认证。
2. 专区展示:商品审核通过后将在首页“极品专区”展示,获得更聚焦的高端账号曝光。 2. 专区展示:商品审核通过后将在首页“极品专区”展示,获得更聚焦的高端账号曝光。
3. 价格规则:系统会按后台配置在基础比例上计算极品专区专享比例,审核页展示的最终价格通过后不再改价。 3. 价格规则:系统会按后台配置在基础比例上计算极品专区专享比例,审核页展示的最终价格通过后不再改价。
4. 保险服务:可选择无忧租号保险,服务费率按优质号主及极品专区的当前配置执行,并从卖家实际到手租金中扣除。 4. 保险服务:可选择无忧租号保险,服务费率按优质号主及极品专区的当前配置执行,并按实际纯币结算金额计算,额外物品不计入保险基数。
5. 责任说明:开启专区不会免除商品审核、资料真实性及发布协议中的责任,请如实填写账号资产与出租条件。` 5. 责任说明:开启专区不会免除商品审核、资料真实性及发布协议中的责任,请如实填写账号资产与出租条件。`
export const emptyListingPublishOptions: ListingPublishOptions = { export const emptyListingPublishOptions: ListingPublishOptions = {
@@ -180,7 +180,7 @@ export const emptyListingPublishOptions: ListingPublishOptions = {
deposit_tooltip: defaultDepositTooltip, deposit_tooltip: defaultDepositTooltip,
premium_zone_tooltip: defaultPremiumZoneTooltip, premium_zone_tooltip: defaultPremiumZoneTooltip,
compensation_note: compensation_note:
'普通赔付方案如下:账号在租期内或祖期外出现封禁,追回、洗号等情况,最高可获赔全款相金+押金\n\n无忧保险赔付方案:按账号市场价值进行赔付(可参照螃蟹,盼之等主流交易平台)。注:选择此方案,需额外抽取订单佣金的8.0%作为服务费:押金须达到推荐押金方可购买。', '普通赔付方案如下:账号在租期内或祖期外出现封禁,追回、洗号等情况,最高可获赔全款相金+押金\n\n无忧保险赔付方案:按账号市场价值进行赔付(可参照螃蟹,盼之等主流交易平台)。无忧保险服务费按实际纯币结算金额计算,额外物品不计入计费基数;具体费率以当前配置为准。押金须达到推荐押金方可购买。',
price_placeholder: '', price_placeholder: '',
ratio_description: '', ratio_description: '',
}, },
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import type { Order } from '@/features/orders/api/orders'
import type { CounterFormState } from '@/features/orders/composables/useOrderActions'
import { useCheckoutSimulator } from '@/features/orders/composables/useCheckoutSimulator'
function orderFixture(): Order {
return {
id: 1,
order_no: 'ORDER-1',
listing_id: 1,
listing_no: 'LISTING-1',
account_id: 1,
owner_id: 1,
renter_id: 2,
title: '测试账号',
server_region: '国服',
login_platform: '扫码',
estimated_duration_hours: 24,
display_amount_cent: 25000,
rent_amount_cent: 25000,
owner_rent_amount_cent: 25000,
deposit_amount_cent: 15000,
deposit_original_amount_cent: 15000,
deposit_waived_amount_cent: 0,
insurance_fee_bps: 800,
account_snapshot: {
haf_coin_amount: 100_000_000,
asset_summary: {
price_breakdown: {
buyer_ratio: 40,
seller_ratio: 40,
buyer_coin_base_price: 250,
seller_coin_base_price: 250,
consumable_price: 0,
},
},
},
status: 'renting',
handoff_status: 'received',
settlement_status: 'unsettled',
created_at: '2026-11-05T00:00:00+08:00',
updated_at: '2026-11-05T00:00:00+08:00',
}
}
describe('useCheckoutSimulator insurance fee', () => {
it('按实际纯币结算金额计费,额外物品不进入保险基数', () => {
const form = ref<CounterFormState>({
consumableAmountYuan: 50,
coin_consumed_m: 50,
depositDeductAmountYuan: 0,
reason: '',
evidenceText: '',
})
const simulator = useCheckoutSimulator({
order: ref(orderFixture()),
isOwner: ref(true),
isRenter: ref(false),
form,
})
expect(simulator.value?.role).toBe('owner')
if (!simulator.value || simulator.value.role !== 'owner') {
throw new Error('owner simulation missing')
}
expect(simulator.value.insuranceFeeYuan).toBe(10)
expect(simulator.value.rentIncomeYuan).toBe(165)
expect(simulator.value.lossCompensationYuan).toBe(50)
})
})
@@ -102,7 +102,7 @@ export function useCheckoutSimulator(options: {
const grossOwnerRentIncomeYuan = roundMoney(ownerCoinYuan + ownerConsumableYuan) const grossOwnerRentIncomeYuan = roundMoney(ownerCoinYuan + ownerConsumableYuan)
const insuranceFeeYuan = roundMoney( const insuranceFeeYuan = roundMoney(
(grossOwnerRentIncomeYuan * Number(o.insurance_fee_bps || 0)) / 10000 (ownerCoinYuan * Number(o.insurance_fee_bps || 0)) / 10000
) )
const ownerRentIncomeYuan = Math.max( const ownerRentIncomeYuan = Math.max(
roundMoney(grossOwnerRentIncomeYuan - insuranceFeeYuan), roundMoney(grossOwnerRentIncomeYuan - insuranceFeeYuan),
@@ -120,7 +120,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
}) })
const calculatedInsuranceFee = computed(() => const calculatedInsuranceFee = computed(() =>
form.compensation_plan === 'insurance' form.compensation_plan === 'insurance'
? calculateInsuranceFeeAmount(pricing.calculatedSellerPrice.value, insuranceFeeBps.value) ? calculateInsuranceFeeAmount(pricing.calculatedCoinBasePrice.value, insuranceFeeBps.value)
: 0 : 0
) )
const calculatedTakeHomePrice = computed(() => const calculatedTakeHomePrice = computed(() =>
@@ -948,7 +948,7 @@ const screenshotGuides: Record<string, string> = {
<div class="coverage-heading"> <div class="coverage-heading">
<strong>选择赔付方案</strong> <strong>选择赔付方案</strong>
<span>服务费按实际到手租金结算扣除</span> <span>按实际纯币结算金额计费,额外物品不计入</span>
</div> </div>
<div class="coverage-option-grid"> <div class="coverage-option-grid">
<button <button
@@ -973,7 +973,7 @@ const screenshotGuides: Record<string, string> = {
<strong>无忧租号保险</strong> <strong>无忧租号保险</strong>
<em class="coverage-rate is-insurance"> {{ insuranceFeePercent.toFixed(1) }}% </em> <em class="coverage-rate is-insurance"> {{ insuranceFeePercent.toFixed(1) }}% </em>
</span> </span>
<span>额外保障账号市场价值</span> <span>额外保障账号市场价值,按纯币结算金额计费</span>
<em v-if="isQualityOwner && form.is_premium" class="coverage-special-rate"> <em v-if="isQualityOwner && form.is_premium" class="coverage-special-rate">
已享极品专区专享费率 已享极品专区专享费率
</em> </em>
@@ -989,7 +989,7 @@ const screenshotGuides: Record<string, string> = {
</div> </div>
<div> <div>
<span v-if="form.compensation_plan === 'insurance'"> <span v-if="form.compensation_plan === 'insurance'">
无忧租号保险({{ insuranceFeePercent.toFixed(1) }}%) 无忧租号保险({{ insuranceFeePercent.toFixed(1) }}% · 纯币计费)
</span> </span>
<span v-else>普通赔付</span> <span v-else>普通赔付</span>
<strong :class="{ 'is-deduction': form.compensation_plan === 'insurance' }"> <strong :class="{ 'is-deduction': form.compensation_plan === 'insurance' }">
@@ -772,7 +772,7 @@ function selectDailyLoss(value: string | number) {
<div class="coverage-heading"> <div class="coverage-heading">
<strong>选择赔付方案</strong> <strong>选择赔付方案</strong>
<span>服务费按号主实际到手租金计算,结算时自动扣除</span> <span>保险服务费按实际纯币结算金额计算,额外物品不计入基数</span>
</div> </div>
<div class="coverage-option-grid"> <div class="coverage-option-grid">
<button <button
@@ -799,7 +799,7 @@ function selectDailyLoss(value: string | number) {
{{ insuranceFeePercent.toFixed(1) }}% {{ insuranceFeePercent.toFixed(1) }}%
</em> </em>
</span> </span>
<span>额外保障账号市场价值,费率按实际到手租金扣除</span> <span>额外保障账号市场价值,费率按实际纯币结算金额扣除</span>
<em v-if="isQualityOwner && form.is_premium" class="coverage-special-rate"> <em v-if="isQualityOwner && form.is_premium" class="coverage-special-rate">
已享极品专区专享费率 已享极品专区专享费率
</em> </em>
@@ -815,7 +815,7 @@ function selectDailyLoss(value: string | number) {
</div> </div>
<div> <div>
<span v-if="form.compensation_plan === 'insurance'"> <span v-if="form.compensation_plan === 'insurance'">
无忧租号保险({{ insuranceFeePercent.toFixed(1) }}%) 无忧租号保险({{ insuranceFeePercent.toFixed(1) }}% · 纯币计费)
</span> </span>
<span v-else>普通赔付</span> <span v-else>普通赔付</span>
<strong :class="{ 'is-deduction': form.compensation_plan === 'insurance' }"> <strong :class="{ 'is-deduction': form.compensation_plan === 'insurance' }">
@@ -873,7 +873,7 @@ function selectDailyLoss(value: string | number) {
<strong>¥{{ formatMoney(calculatedConsumablePrice) }}</strong> <strong>¥{{ formatMoney(calculatedConsumablePrice) }}</strong>
</div> </div>
<div v-if="form.compensation_plan === 'insurance'"> <div v-if="form.compensation_plan === 'insurance'">
<span>无忧租号保险({{ insuranceFeePercent.toFixed(1) }}%)</span> <span>无忧租号保险({{ insuranceFeePercent.toFixed(1) }}% · 纯币计费)</span>
<strong class="is-deduction">-¥{{ formatMoney(calculatedInsuranceFee) }}</strong> <strong class="is-deduction">-¥{{ formatMoney(calculatedInsuranceFee) }}</strong>
</div> </div>
<div> <div>
@@ -15,7 +15,11 @@ describe('calculateInsuranceFeeAmount', () => {
expect(calculateInsuranceFeeAmount(238.1, 400)).toBe(9.52) expect(calculateInsuranceFeeAmount(238.1, 400)).toBe(9.52)
}) })
it('保险服务费不超过卖家租金', () => { it('按纯币价格计算保险服务费', () => {
expect(calculateInsuranceFeeAmount(250, 800)).toBe(20)
})
it('保险服务费不超过纯币金额', () => {
expect(calculateInsuranceFeeAmount(1, 10_000)).toBe(1) expect(calculateInsuranceFeeAmount(1, 10_000)).toBe(1)
}) })
}) })
+4 -4
View File
@@ -26,11 +26,11 @@ export function roundMoney(value: number) {
return Math.round(value * 10) / 10 return Math.round(value * 10) / 10
} }
export function calculateInsuranceFeeAmount(ownerIncomeYuan: number, feeBps: number) { export function calculateInsuranceFeeAmount(pureCoinAmountYuan: number, feeBps: number) {
const ownerIncomeCent = Math.max(Math.round(Number(ownerIncomeYuan || 0) * 100), 0) const pureCoinAmountCent = Math.max(Math.round(Number(pureCoinAmountYuan || 0) * 100), 0)
const safeFeeBps = Math.max(Math.round(Number(feeBps || 0)), 0) const safeFeeBps = Math.max(Math.round(Number(feeBps || 0)), 0)
if (ownerIncomeCent <= 0 || safeFeeBps <= 0) return 0 if (pureCoinAmountCent <= 0 || safeFeeBps <= 0) return 0
return Math.min(Math.round((ownerIncomeCent * safeFeeBps) / 10_000), ownerIncomeCent) / 100 return Math.min(Math.round((pureCoinAmountCent * safeFeeBps) / 10_000), pureCoinAmountCent) / 100
} }
/** /**