fix: adjust mifapay inr amount formatting

This commit is contained in:
zhx 2026-07-06 20:15:36 +08:00
parent 0d4ce59c10
commit 8e5f213d9b
6 changed files with 157 additions and 9 deletions

View File

@ -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

View File

@ -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) {

View File

@ -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
}

View File

@ -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

View File

@ -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 {

View File

@ -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) {