From b408edf97b0e7780ff1960ffb46f48edd28ac823 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Mon, 6 Jul 2026 10:56:43 +0800 Subject: [PATCH] fix(payment): convert subscription CNY pay amount --- .../service/payment_fulfillment_test.go | 12 +++---- backend/internal/service/payment_order.go | 24 +++++++++++-- .../service/payment_order_result_test.go | 28 ++++++++++----- frontend/src/views/user/PaymentView.vue | 13 ++++--- .../views/user/__tests__/PaymentView.spec.ts | 35 +++++++++---------- 5 files changed, 73 insertions(+), 39 deletions(-) diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index b46d6a1fc8..a8c78d713c 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -602,8 +602,8 @@ func TestExecuteSubscriptionFulfillmentAppliesAffiliateRebate(t *testing.T) { SetUserID(user.ID). SetUserEmail(user.Email). SetUserName(user.Username). - SetAmount(120). - SetPayAmount(120). + SetAmount(9.99). + SetPayAmount(71.36). SetFeeRate(0). SetRechargeCode("PAY-SUB-AFFILIATE"). SetOutTradeNo("sub2_subscription_affiliate"). @@ -636,7 +636,7 @@ func TestExecuteSubscriptionFulfillmentAppliesAffiliateRebate(t *testing.T) { } settingSvc := NewSettingService(&paymentFulfillmentSettingRepoStub{values: map[string]string{ SettingKeyAffiliateEnabled: "true", - SettingKeyAffiliateRebateRate: "20", + SettingKeyAffiliateRebateRate: "15", SettingKeyAffiliateRebateFreezeHours: "0", }}, nil) subRepo := newSubscriptionUserSubRepoStub() @@ -659,7 +659,7 @@ func TestExecuteSubscriptionFulfillmentAppliesAffiliateRebate(t *testing.T) { require.Len(t, affiliateRepo.accrueCalls, 1) require.Equal(t, inviterID, affiliateRepo.accrueCalls[0].inviterID) require.Equal(t, user.ID, affiliateRepo.accrueCalls[0].inviteeUserID) - require.Equal(t, 24.0, affiliateRepo.accrueCalls[0].amount) + require.InDelta(t, 1.4985, affiliateRepo.accrueCalls[0].amount, 0.00000001) require.NotNil(t, affiliateRepo.accrueCalls[0].sourceOrderID) require.Equal(t, order.ID, *affiliateRepo.accrueCalls[0].sourceOrderID) require.Equal(t, 1, subRepo.createCalls) @@ -668,8 +668,8 @@ func TestExecuteSubscriptionFulfillmentAppliesAffiliateRebate(t *testing.T) { Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("AFFILIATE_REBATE_APPLIED")). Only(ctx) require.NoError(t, err) - require.Contains(t, applied.Detail, `"baseAmount":120`) - require.Contains(t, applied.Detail, `"rebateAmount":24`) + require.Contains(t, applied.Detail, `"baseAmount":9.99`) + require.Contains(t, applied.Detail, `"rebateAmount":1.4985`) } func TestExecuteSubscriptionFulfillmentDoesNotDuplicateWorkAfterLegacySuccessAudit(t *testing.T) { diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index 154159b932..7f4bcf7c2d 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -16,6 +16,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/payment" "github.com/Wei-Shaw/sub2api/internal/payment/provider" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/shopspring/decimal" ) // --- Order Creation --- @@ -67,8 +68,7 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest return nil, err } } - // 订阅套餐 price 是直付价,余额充值倍率只影响余额充值到账,不参与订阅 pay_amount 计算。 - payAmountStr, payAmount, err := calculateCreateOrderPayAmount(limitAmount, feeRate, methodCurrency) + payAmountStr, payAmount, err := calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, methodCurrency, req.OrderType, cfg.BalanceRechargeMultiplier) if err != nil { return nil, err } @@ -84,7 +84,7 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest selectedCurrency = paymentProviderConfigCurrency(sel.ProviderKey, sel.Config) } if selectedCurrency != methodCurrency { - payAmountStr, payAmount, err = calculateCreateOrderPayAmount(limitAmount, feeRate, selectedCurrency) + payAmountStr, payAmount, err = calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, selectedCurrency, req.OrderType, cfg.BalanceRechargeMultiplier) if err != nil { return nil, err } @@ -630,6 +630,24 @@ func calculateCreateOrderPayAmount(limitAmount, feeRate float64, currency string return payAmountStr, payAmount, nil } +func calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate float64, currency, orderType string, multiplier float64) (string, float64, error) { + paymentAmount := limitAmount + if orderType == payment.OrderTypeSubscription { + paymentAmount = calculateSubscriptionGatewayBaseAmount(limitAmount, multiplier, currency) + } + return calculateCreateOrderPayAmount(paymentAmount, feeRate, currency) +} + +func calculateSubscriptionGatewayBaseAmount(amount, multiplier float64, currency string) float64 { + if currency != payment.DefaultPaymentCurrency { + return amount + } + return decimal.NewFromFloat(amount). + Div(decimal.NewFromFloat(normalizeBalanceRechargeMultiplier(multiplier))). + Round(int32(payment.CurrencyMaxFractionDigits(currency))). + InexactFloat64() +} + func validateCreateOrderAmountCurrency(amount float64, currency string) error { amountStr := strconv.FormatFloat(amount, 'f', -1, 64) if _, err := payment.AmountToMinorUnit(amountStr, currency); err != nil { diff --git a/backend/internal/service/payment_order_result_test.go b/backend/internal/service/payment_order_result_test.go index 14192bd6cd..930643d3e0 100644 --- a/backend/internal/service/payment_order_result_test.go +++ b/backend/internal/service/payment_order_result_test.go @@ -161,27 +161,39 @@ func TestCalculateCreateOrderPayAmountUsesCurrencyPrecision(t *testing.T) { } } -func TestCalculateCreateOrderPayAmountForSubscriptionKeepsDirectPrice(t *testing.T) { +func TestCalculateCreateOrderPayAmountForSubscriptionConvertsCNYPrice(t *testing.T) { t.Parallel() - amountStr, amount, err := calculateCreateOrderPayAmount(5, 0, "CNY") + amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "CNY", payment.OrderTypeSubscription, 0.14) if err != nil { t.Fatalf("unexpected error: %v", err) } - if amountStr != "5.00" || amount != 5 { - t.Fatalf("subscription CNY pay amount = (%q, %v), want (5.00, 5)", amountStr, amount) + if amountStr != "71.36" || amount != 71.36 { + t.Fatalf("subscription CNY pay amount = (%q, %v), want (71.36, 71.36)", amountStr, amount) } } -func TestCalculateCreateOrderPayAmountForSubscriptionAppliesFeeToDirectPrice(t *testing.T) { +func TestCalculateCreateOrderPayAmountForSubscriptionAppliesFeeAfterCNYConversion(t *testing.T) { t.Parallel() - amountStr, amount, err := calculateCreateOrderPayAmount(5, 2.5, "CNY") + amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 2.5, "CNY", payment.OrderTypeSubscription, 0.14) if err != nil { t.Fatalf("unexpected error: %v", err) } - if amountStr != "5.13" || amount != 5.13 { - t.Fatalf("subscription CNY pay amount with fee = (%q, %v), want (5.13, 5.13)", amountStr, amount) + if amountStr != "73.15" || amount != 73.15 { + t.Fatalf("subscription CNY pay amount with fee = (%q, %v), want (73.15, 73.15)", amountStr, amount) + } +} + +func TestCalculateCreateOrderPayAmountForSubscriptionKeepsNonCNYPrice(t *testing.T) { + t.Parallel() + + amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "USD", payment.OrderTypeSubscription, 0.14) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if amountStr != "9.99" || amount != 9.99 { + t.Fatalf("subscription USD pay amount = (%q, %v), want (9.99, 9.99)", amountStr, amount) } } diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index e3901ea0af..2443e5873b 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -283,7 +283,7 @@ import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, pl import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue' import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue' import Icon from '@/components/icons/Icon.vue' -import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency' +import { DEFAULT_PAYMENT_CURRENCY, formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency' import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue' import { buildPaymentErrorToastMessage, describePaymentScenarioError } from './paymentUx' import { hasWechatResumeQuery, parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume' @@ -579,12 +579,17 @@ function ceilPaymentAmount(value: number, currency: string): number { return Math.ceil(value * factor) / factor } +function subscriptionPaymentAmountForCurrency(value: number, currency: string): number { + if (currency !== DEFAULT_PAYMENT_CURRENCY) return roundPaymentAmount(value, currency) + return roundPaymentAmount(value / balanceRechargeMultiplier.value, currency) +} + function formatSelectedPaymentAmount(value: number): string { return formatPaymentAmount(value, selectedCurrency.value, localeCode.value) } function formatSelectedSubscriptionPaymentAmount(value: number): string { - return formatSelectedPaymentAmount(roundPaymentAmount(value, selectedCurrency.value)) + return formatSelectedPaymentAmount(subscriptionPaymentAmountForCurrency(value, selectedCurrency.value)) } const methodOptions = computed(() => @@ -633,7 +638,7 @@ const canSubmit = computed(() => const subPaymentAmount = computed(() => { const price = selectedPlan.value?.price ?? 0 - return roundPaymentAmount(price, selectedCurrency.value) + return subscriptionPaymentAmountForCurrency(price, selectedCurrency.value) }) const subFeeAmount = computed(() => { @@ -647,7 +652,7 @@ const subTotalAmount = computed(() => { }) function subscriptionTotalAmountForCurrency(value: number, currency: string): number { - const paymentAmount = roundPaymentAmount(value, currency) + const paymentAmount = subscriptionPaymentAmountForCurrency(value, currency) if (feeRate.value <= 0 || paymentAmount <= 0) return paymentAmount const fee = ceilPaymentAmount((paymentAmount * feeRate.value) / 100, currency) return roundPaymentAmount(paymentAmount + fee, currency) diff --git a/frontend/src/views/user/__tests__/PaymentView.spec.ts b/frontend/src/views/user/__tests__/PaymentView.spec.ts index 3b16d42af1..6dbae1f538 100644 --- a/frontend/src/views/user/__tests__/PaymentView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentView.spec.ts @@ -236,29 +236,28 @@ async function mountSubscriptionConfirm(options: Parameters { - it('keeps subscription plan price independent from balance recharge multiplier', async () => { + it('shows converted CNY pay amount for plan price, original price, and create button', async () => { const wrapper = await mountSubscriptionConfirm({ checkout: { - balance_recharge_multiplier: 4, + balance_recharge_multiplier: 0.14, }, method: { currency: 'CNY', }, plan: { - price: 200, - original_price: 300, + price: 9.99, + original_price: 12.99, }, }) const text = wrapper.text() - const planPrice = formatPaymentAmount(200, 'CNY') - const originalPrice = formatPaymentAmount(300, 'CNY') - const convertedByRechargeMultiplier = formatPaymentAmount(50, 'CNY') + const convertedPrice = formatPaymentAmount(71.36, 'CNY') + const convertedOriginalPrice = formatPaymentAmount(92.79, 'CNY') - expect(text).toContain(planPrice) - expect(text).toContain(originalPrice) - expect(text).not.toContain(convertedByRechargeMultiplier) - expect(wrapper.findAll('button').some(button => button.text().includes(planPrice))).toBe(true) + expect(text).toContain(convertedPrice) + expect(text).toContain(convertedOriginalPrice) + expect(text).not.toContain(formatPaymentAmount(9.99, 'CNY')) + expect(wrapper.findAll('button').some(button => button.text().includes(convertedPrice))).toBe(true) }) it('keeps plan price when multiplier is not configured or payment currency is not CNY', async () => { @@ -294,26 +293,26 @@ describe('PaymentView subscription confirmation amounts', () => { expect(usdWrapper.text()).toContain(formatPaymentAmount(9.99, 'USD')) }) - it('adds fee rate to the direct subscription plan price to match backend pay_amount', async () => { + it('adds fee rate after CNY multiplier conversion to match backend pay_amount', async () => { const wrapper = await mountSubscriptionConfirm({ checkout: { - balance_recharge_multiplier: 4, + balance_recharge_multiplier: 0.14, recharge_fee_rate: 2.5, }, method: { currency: 'CNY', }, plan: { - price: 7.99, + price: 9.99, }, }) const text = wrapper.text() - const price = formatPaymentAmount(7.99, 'CNY') - const fee = formatPaymentAmount(0.20, 'CNY') - const total = formatPaymentAmount(8.19, 'CNY') + const convertedPrice = formatPaymentAmount(71.36, 'CNY') + const fee = formatPaymentAmount(1.79, 'CNY') + const total = formatPaymentAmount(73.15, 'CNY') - expect(text).toContain(price) + expect(text).toContain(convertedPrice) expect(text).toContain(fee) expect(text).toContain(total) expect(wrapper.findAll('button').some(button => button.text().includes(total))).toBe(true)