From f455df91923bf41cb2092e7f12e7cc683ed24578 Mon Sep 17 00:00:00 2001 From: local Date: Tue, 23 Jun 2026 23:08:18 +0800 Subject: [PATCH] fix mifapay zero-decimal currency amount --- .../service/wallet/external_recharge_order.go | 4 +-- .../wallet/external_recharge_validate.go | 20 ++++++++++-- .../internal/service/wallet/service_test.go | 31 +++++++++++++++++++ .../internal/testutil/mysqltest/mysqltest.go | 14 +++++++++ 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/services/wallet-service/internal/service/wallet/external_recharge_order.go b/services/wallet-service/internal/service/wallet/external_recharge_order.go index 74cbdd11..521d671a 100644 --- a/services/wallet-service/internal/service/wallet/external_recharge_order.go +++ b/services/wallet-service/internal/service/wallet/external_recharge_order.go @@ -60,7 +60,7 @@ func (s *Service) createMifaPayRechargeOrder(ctx context.Context, command ledger if err := validateMifaPayMethodForOrder(method, command); err != nil { return ledger.ExternalRechargeOrder{}, err } - providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate) + providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate, method.CurrencyCode) if err != nil { return ledger.ExternalRechargeOrder{}, err } @@ -111,7 +111,7 @@ func (s *Service) createV5PayRechargeOrder(ctx context.Context, command ledger.C if err := validateV5PayMethodForOrder(method, command); err != nil { return ledger.ExternalRechargeOrder{}, err } - providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate) + providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate, method.CurrencyCode) if err != nil { return ledger.ExternalRechargeOrder{}, err } diff --git a/services/wallet-service/internal/service/wallet/external_recharge_validate.go b/services/wallet-service/internal/service/wallet/external_recharge_validate.go index 195cc697..7e31372d 100644 --- a/services/wallet-service/internal/service/wallet/external_recharge_validate.go +++ b/services/wallet-service/internal/service/wallet/external_recharge_validate.go @@ -116,7 +116,7 @@ func validateMifaPayMethodForOrder(method ledger.ThirdPartyPaymentMethod, comman if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) { return xerr.New(xerr.Conflict, "mifapay method country does not match") } - if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate); err != nil { + if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate, method.CurrencyCode); err != nil { return err } return nil @@ -132,13 +132,13 @@ func validateV5PayMethodForOrder(method ledger.ThirdPartyPaymentMethod, command if strings.TrimSpace(method.PayType) == "" { return xerr.New(xerr.InvalidArgument, "v5pay product type is required") } - if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate); err != nil { + if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate, method.CurrencyCode); err != nil { return err } return nil } -func calculateProviderAmountMinor(usdMinor int64, rate string) (int64, error) { +func calculateProviderAmountMinor(usdMinor int64, rate string, currencyCode string) (int64, error) { if usdMinor <= 0 { return 0, xerr.New(xerr.InvalidArgument, "usd amount is invalid") } @@ -146,9 +146,23 @@ func calculateProviderAmountMinor(usdMinor int64, rate string) (int64, error) { if err != nil || value <= 0 { return 0, xerr.New(xerr.InvalidArgument, "payment exchange rate is invalid") } + if providerCurrencyScale(currencyCode) == 0 { + // MiFaPay still receives a fixed two-decimal integer amount, but zero-decimal currencies + // such as IDR must represent a whole major-unit amount after the provider divides by 100. + return int64(math.Round(float64(usdMinor)/100*value)) * 100, nil + } return int64(math.Round(float64(usdMinor) * value)), nil } +func providerCurrencyScale(currencyCode string) int { + switch strings.ToUpper(strings.TrimSpace(currencyCode)) { + case "BIF", "CLP", "DJF", "GNF", "IDR", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF": + return 0 + default: + return 2 + } +} + func amountMicroToUSDMinor(amountMicro int64) int64 { if amountMicro <= 0 { return 0 diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index 340e23db..4bd0225a 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -2452,6 +2452,37 @@ func TestH5MifaPayOrderRejectsPaymentMethodFromAnotherCountry(t *testing.T) { } } +// TestH5MifaPayOrderRoundsZeroDecimalCurrency 锁定 IDR 等零小数币种的三方下单金额: +// MiFaPay 按 fixed minor 接收 amount,但会校验 IDR 主单位不能带小数,因此本地要把主单位先归整。 +func TestH5MifaPayOrderRoundsZeroDecimalCurrency(t *testing.T) { + repository := mysqltest.NewRepository(t) + svc := walletservice.New(repository) + mifaPay := &fakeMifaPayClient{} + svc.SetMifaPayClient(mifaPay) + svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"}) + product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7201, 300_000_000, 28_200_000) + idBankMethodID := repository.ThirdPartyPaymentMethodID("ID", "BankTransfer", "BCA") + repository.SetThirdPartyPaymentRate(idBankMethodID, "17706.65883333") + + order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{ + AppCode: appcode.Default, + CommandID: "cmd-h5-mifapay-idr-integer", + TargetUserID: 42002, + TargetRegionID: 7201, + TargetCountryCode: "ID", + AudienceType: ledger.RechargeAudienceNormal, + ProductID: product.ProductID, + ProviderCode: ledger.PaymentProviderMifaPay, + PaymentMethodID: idBankMethodID, + }) + if err != nil { + t.Fatalf("CreateH5RechargeOrder IDR failed: %v", err) + } + if order.ProviderAmountMinor != 531_199_800 || mifaPay.createReq.ProviderAmountMinor != 531_199_800 { + t.Fatalf("IDR amount must be rounded to whole major units before fixed-minor submit: order=%+v req=%+v", order, mifaPay.createReq) + } +} + // TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable 锁定 MiFaPay 网关拒单的边界:本地订单要落失败便于排障, // 对外错误必须保持上游依赖失败语义,不能伪装成商品、幂等或状态冲突。 func TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable(t *testing.T) { diff --git a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go index 0d19b2fe..21844eb9 100644 --- a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go @@ -410,6 +410,20 @@ func (r *Repository) ThirdPartyProviderPaymentMethodID(providerCode string, coun return methodID } +// SetThirdPartyPaymentRate updates the production payment method row so service tests can lock provider amount semantics. +func (r *Repository) SetThirdPartyPaymentRate(methodID int64, rate string) { + r.t.Helper() + + if _, err := r.schema.DB.ExecContext(context.Background(), ` + UPDATE third_party_payment_methods + SET usd_to_currency_rate = ?, updated_at_ms = ? + WHERE method_id = ?`, + strings.TrimSpace(rate), time.Now().UnixMilli(), methodID, + ); err != nil { + r.t.Fatalf("set third party payment rate failed: %v", err) + } +} + // SetGiftPrice overrides or inserts a gift settlement price for server-side billing tests. func (r *Repository) SetGiftPrice(giftID string, priceVersion string, coinPrice int64, giftPointAmount int64, heatValue int64) { r.t.Helper()