From 8e5f213d9b56fabb14cf4f647977d0797aae32a7 Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 6 Jul 2026 20:15:36 +0800 Subject: [PATCH] fix: adjust mifapay inr amount formatting --- .../internal/client/mifapay/client.go | 11 +++- .../internal/client/mifapay/client_test.go | 57 +++++++++++++++++++ .../service/wallet/external_recharge_order.go | 2 +- .../wallet/external_recharge_temporary.go | 4 +- .../wallet/external_recharge_validate.go | 41 +++++++++++-- .../internal/service/wallet/service_test.go | 51 +++++++++++++++++ 6 files changed, 157 insertions(+), 9 deletions(-) diff --git a/services/wallet-service/internal/client/mifapay/client.go b/services/wallet-service/internal/client/mifapay/client.go index 52b0f2f4..186f5510 100644 --- a/services/wallet-service/internal/client/mifapay/client.go +++ b/services/wallet-service/internal/client/mifapay/client.go @@ -64,7 +64,7 @@ func (c *Client) CreateOrder(ctx context.Context, req walletservice.MifaPayCreat } payer := rechargePayer(req) data := map[string]any{ - "amount": strconv.FormatInt(req.ProviderAmountMinor, 10), + "amount": mifaPayProviderAmountValue(req.ProviderAmountMinor, req.CurrencyCode), "orderId": req.OrderID, "payWay": req.PayWay, "language": firstNonEmpty(req.Language, "en"), @@ -364,6 +364,15 @@ func productInfo(req walletservice.MifaPayCreateOrderRequest) string { return string(body) } +func mifaPayProviderAmountValue(providerAmountMinor int64, currencyCode string) string { + if strings.EqualFold(strings.TrimSpace(currencyCode), "INR") { + // MiFaPay India treats amount as whole INR units while wallet snapshots keep local minor units; + // converting at the client boundary keeps V5Pay and non-INR MiFaPay contracts unchanged. + return strconv.FormatInt(providerAmountMinor/100, 10) + } + return strconv.FormatInt(providerAmountMinor, 10) +} + type payerIdentity struct { email string firstName string diff --git a/services/wallet-service/internal/client/mifapay/client_test.go b/services/wallet-service/internal/client/mifapay/client_test.go index 3e20ce4e..f1a9699a 100644 --- a/services/wallet-service/internal/client/mifapay/client_test.go +++ b/services/wallet-service/internal/client/mifapay/client_test.go @@ -83,6 +83,63 @@ func TestCreateOrderMapsGatewayRejectionToUnavailable(t *testing.T) { } } +func TestCreateOrderFormatsINRAmountAsWholeMajorUnits(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/paygateway/mbpay/order/en_v2" { + t.Fatalf("unexpected mifapay path: %s", request.URL.Path) + } + raw, err := io.ReadAll(request.Body) + if err != nil { + t.Fatalf("read mifapay request body: %v", err) + } + var outer envelope + if err := json.Unmarshal(raw, &outer); err != nil { + t.Fatalf("decode mifapay request envelope: %v", err) + } + var data struct { + Amount string `json:"amount"` + Currency string `json:"currency"` + } + if err := json.Unmarshal([]byte(outer.Data), &data); err != nil { + t.Fatalf("decode mifapay request data: %v", err) + } + if data.Amount != "100" || data.Currency != "INR" { + t.Fatalf("mifapay INR amount should be whole major units: %+v", data) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"code":"100002","msg":"stop after request inspection"}`)) + })) + defer server.Close() + + cfg := testMifaPayConfig(t, server.URL) + client, err := New(cfg) + if err != nil { + t.Fatalf("new mifapay client failed: %v", err) + } + + _, err = client.CreateOrder(context.Background(), walletservice.MifaPayCreateOrderRequest{ + OrderID: "h5r_inr_test", + TargetUserID: 10001, + ProductName: "coin", + AmountMinor: 100, + CurrencyCode: "INR", + CountryCode: "IN", + PayWay: "Ewallet", + PayType: "Paytm", + NotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify", + ReturnURL: "https://h5.example.com/recharge/index.html", + ClientIP: "127.0.0.1", + Language: "en", + ProviderAmountMinor: 10_000, + PayerName: "normal55", + PayerAccount: "163055", + PayerEmail: "163055@lalu.com", + }) + if !xerr.IsCode(err, xerr.Unavailable) { + t.Fatalf("expected inspected MiFaPay rejection to map to UNAVAILABLE, got %v", err) + } +} + func TestQueryOrderUsesProviderOrderIDAndParsesSignedStatus(t *testing.T) { var signer *Client server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 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 521d671a..b3c09446 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, method.CurrencyCode) + providerAmountMinor, err := calculateMifaPayProviderAmountMinor(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_temporary.go b/services/wallet-service/internal/service/wallet/external_recharge_temporary.go index 5a351396..85155dac 100644 --- a/services/wallet-service/internal/service/wallet/external_recharge_temporary.go +++ b/services/wallet-service/internal/service/wallet/external_recharge_temporary.go @@ -43,7 +43,7 @@ func (s *Service) createTemporaryMifaPayRechargeOrder(ctx context.Context, comma return ledger.ExternalRechargeOrder{}, err } // 临时链接没有商品档位,三方实付金额只能由运营输入的 USD 金额和支付方式汇率即时换算;订单快照会保存换算结果。 - providerAmountMinor, err := calculateProviderAmountMinor(command.USDMinorAmount, method.USDToCurrencyRate, method.CurrencyCode) + providerAmountMinor, err := calculateMifaPayProviderAmountMinor(command.USDMinorAmount, method.USDToCurrencyRate, method.CurrencyCode) if err != nil { return ledger.ExternalRechargeOrder{}, err } @@ -205,7 +205,7 @@ func validateMifaPayMethodForTemporaryOrder(method ledger.ThirdPartyPaymentMetho if method.ProviderCode != ledger.PaymentProviderMifaPay || method.Status != ledger.ThirdPartyPaymentStatusActive || method.MethodID != command.PaymentMethodID { return xerr.New(xerr.Conflict, "mifapay method is not active") } - if _, err := calculateProviderAmountMinor(command.USDMinorAmount, method.USDToCurrencyRate, method.CurrencyCode); err != nil { + if _, err := calculateMifaPayProviderAmountMinor(command.USDMinorAmount, method.USDToCurrencyRate, method.CurrencyCode); err != nil { return err } return nil 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 7e31372d..9074b738 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, method.CurrencyCode); err != nil { + if _, err := calculateMifaPayProviderAmountMinor(100, method.USDToCurrencyRate, method.CurrencyCode); err != nil { return err } return nil @@ -154,6 +154,39 @@ func calculateProviderAmountMinor(usdMinor int64, rate string, currencyCode stri return int64(math.Round(float64(usdMinor) * value)), nil } +const mifaPayINRMinimumMajorAmount = int64(100) + +func calculateMifaPayProviderAmountMinor(usdMinor int64, rate string, currencyCode string) (int64, error) { + if !mifaPayUsesWholeMajorAmount(currencyCode) { + return calculateProviderAmountMinor(usdMinor, rate, currencyCode) + } + if usdMinor <= 0 { + return 0, xerr.New(xerr.InvalidArgument, "usd amount is invalid") + } + value, err := strconv.ParseFloat(strings.TrimSpace(rate), 64) + if err != nil || value <= 0 { + return 0, xerr.New(xerr.InvalidArgument, "payment exchange rate is invalid") + } + majorAmount := int64(math.Round(float64(usdMinor) / 100 * value)) + if strings.EqualFold(currencyCode, "INR") && majorAmount < mifaPayINRMinimumMajorAmount { + // MiFaPay 印度通道要求 amount 是整数卢比,且当前网关最小金额为 100 INR; + // 本地仍按 minor 保存 10000,回调和账单都能表达实际向三方提交的收款金额。 + majorAmount = mifaPayINRMinimumMajorAmount + } + return majorAmount * 100, nil +} + +func mifaPayProviderAmountValue(providerAmountMinor int64, currencyCode string) string { + if mifaPayUsesWholeMajorAmount(currencyCode) { + return strconv.FormatInt(providerAmountMinor/100, 10) + } + return strconv.FormatInt(providerAmountMinor, 10) +} + +func mifaPayUsesWholeMajorAmount(currencyCode string) bool { + return strings.EqualFold(strings.TrimSpace(currencyCode), "INR") +} + 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": @@ -182,8 +215,7 @@ func mifaPayNotifyMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRech if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) { return false } - amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64) - return err == nil && amount == order.ProviderAmountMinor + return strings.TrimSpace(data.Amount) == mifaPayProviderAmountValue(order.ProviderAmountMinor, order.CurrencyCode) } func mifaPayQueryMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRechargeOrder) bool { @@ -196,8 +228,7 @@ func mifaPayQueryMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRecha if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) { return false } - amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64) - return err == nil && amount == order.ProviderAmountMinor + return strings.TrimSpace(data.Amount) == mifaPayProviderAmountValue(order.ProviderAmountMinor, order.CurrencyCode) } func v5PayOrderDataMatchesOrder(data V5PayNotifyData, order ledger.ExternalRechargeOrder) bool { diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index 117fb6b8..fa34b761 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -2700,6 +2700,57 @@ func TestH5MifaPayOrderRoundsZeroDecimalCurrency(t *testing.T) { } } +// TestH5MifaPayOrderUsesWholeINRProviderAmount 锁定印度 MiFaPay 的金额边界:上游 amount 传整数卢比, +// 且 1 USD 档位按 99 INR 汇率会低于网关 100 INR 下限,本地订单必须保存实际提交的 100.00 INR。 +func TestH5MifaPayOrderUsesWholeINRProviderAmount(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, 7210, 1_000_000, 80300) + inPaytmMethodID := repository.ThirdPartyPaymentMethodID("IN", "Ewallet", "Paytm") + repository.SetThirdPartyPaymentRate(inPaytmMethodID, "99.00000000") + + order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{ + AppCode: appcode.Default, + CommandID: "cmd-h5-mifapay-inr-minimum", + TargetUserID: 42003, + TargetRegionID: 7210, + TargetCountryCode: "IN", + AudienceType: ledger.RechargeAudienceNormal, + ProductID: product.ProductID, + ProviderCode: ledger.PaymentProviderMifaPay, + PaymentMethodID: inPaytmMethodID, + PayerName: "normal55", + PayerAccount: "163055", + PayerEmail: "normal55@lalu.com", + }) + if err != nil { + t.Fatalf("CreateH5RechargeOrder INR failed: %v", err) + } + if order.ProviderAmountMinor != 10_000 || mifaPay.createReq.ProviderAmountMinor != 10_000 { + t.Fatalf("INR amount must respect MiFaPay 100 INR minimum: order=%+v req=%+v", order, mifaPay.createReq) + } + + mifaPay.queryResp = walletservice.MifaPayQueryOrderResponse{ + OrderID: order.OrderID, + ProviderOrderID: order.ProviderOrderID, + Status: "1", + Amount: "100", + Currency: order.CurrencyCode, + PayType: order.PayType, + RawJSON: `{"status":"1","amount":"100","currency":"INR"}`, + } + credited, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID) + if err != nil { + t.Fatalf("GetH5RechargeOrder INR query failed: %v", err) + } + if credited.Status != ledger.ExternalRechargeStatusCredited || credited.ProviderAmountMinor != 10_000 { + t.Fatalf("INR query should match whole-major MiFaPay amount and credit: %+v", credited) + } +} + // TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable 锁定 MiFaPay 网关拒单的边界:本地订单要落失败便于排障, // 对外错误必须保持上游依赖失败语义,不能伪装成商品、幂等或状态冲突。 func TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable(t *testing.T) {