fix: adjust mifapay inr amount formatting
This commit is contained in:
parent
0d4ce59c10
commit
8e5f213d9b
@ -64,7 +64,7 @@ func (c *Client) CreateOrder(ctx context.Context, req walletservice.MifaPayCreat
|
|||||||
}
|
}
|
||||||
payer := rechargePayer(req)
|
payer := rechargePayer(req)
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"amount": strconv.FormatInt(req.ProviderAmountMinor, 10),
|
"amount": mifaPayProviderAmountValue(req.ProviderAmountMinor, req.CurrencyCode),
|
||||||
"orderId": req.OrderID,
|
"orderId": req.OrderID,
|
||||||
"payWay": req.PayWay,
|
"payWay": req.PayWay,
|
||||||
"language": firstNonEmpty(req.Language, "en"),
|
"language": firstNonEmpty(req.Language, "en"),
|
||||||
@ -364,6 +364,15 @@ func productInfo(req walletservice.MifaPayCreateOrderRequest) string {
|
|||||||
return string(body)
|
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 {
|
type payerIdentity struct {
|
||||||
email string
|
email string
|
||||||
firstName string
|
firstName string
|
||||||
|
|||||||
@ -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) {
|
func TestQueryOrderUsesProviderOrderIDAndParsesSignedStatus(t *testing.T) {
|
||||||
var signer *Client
|
var signer *Client
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
|||||||
@ -60,7 +60,7 @@ func (s *Service) createMifaPayRechargeOrder(ctx context.Context, command ledger
|
|||||||
if err := validateMifaPayMethodForOrder(method, command); err != nil {
|
if err := validateMifaPayMethodForOrder(method, command); err != nil {
|
||||||
return ledger.ExternalRechargeOrder{}, err
|
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 {
|
if err != nil {
|
||||||
return ledger.ExternalRechargeOrder{}, err
|
return ledger.ExternalRechargeOrder{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,7 +43,7 @@ func (s *Service) createTemporaryMifaPayRechargeOrder(ctx context.Context, comma
|
|||||||
return ledger.ExternalRechargeOrder{}, err
|
return ledger.ExternalRechargeOrder{}, err
|
||||||
}
|
}
|
||||||
// 临时链接没有商品档位,三方实付金额只能由运营输入的 USD 金额和支付方式汇率即时换算;订单快照会保存换算结果。
|
// 临时链接没有商品档位,三方实付金额只能由运营输入的 USD 金额和支付方式汇率即时换算;订单快照会保存换算结果。
|
||||||
providerAmountMinor, err := calculateProviderAmountMinor(command.USDMinorAmount, method.USDToCurrencyRate, method.CurrencyCode)
|
providerAmountMinor, err := calculateMifaPayProviderAmountMinor(command.USDMinorAmount, method.USDToCurrencyRate, method.CurrencyCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ledger.ExternalRechargeOrder{}, err
|
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 {
|
if method.ProviderCode != ledger.PaymentProviderMifaPay || method.Status != ledger.ThirdPartyPaymentStatusActive || method.MethodID != command.PaymentMethodID {
|
||||||
return xerr.New(xerr.Conflict, "mifapay method is not active")
|
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 err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -116,7 +116,7 @@ func validateMifaPayMethodForOrder(method ledger.ThirdPartyPaymentMethod, comman
|
|||||||
if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) {
|
if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) {
|
||||||
return xerr.New(xerr.Conflict, "mifapay method country does not match")
|
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 err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@ -154,6 +154,39 @@ func calculateProviderAmountMinor(usdMinor int64, rate string, currencyCode stri
|
|||||||
return int64(math.Round(float64(usdMinor) * value)), nil
|
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 {
|
func providerCurrencyScale(currencyCode string) int {
|
||||||
switch strings.ToUpper(strings.TrimSpace(currencyCode)) {
|
switch strings.ToUpper(strings.TrimSpace(currencyCode)) {
|
||||||
case "BIF", "CLP", "DJF", "GNF", "IDR", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF":
|
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) {
|
if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64)
|
return strings.TrimSpace(data.Amount) == mifaPayProviderAmountValue(order.ProviderAmountMinor, order.CurrencyCode)
|
||||||
return err == nil && amount == order.ProviderAmountMinor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mifaPayQueryMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRechargeOrder) bool {
|
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) {
|
if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64)
|
return strings.TrimSpace(data.Amount) == mifaPayProviderAmountValue(order.ProviderAmountMinor, order.CurrencyCode)
|
||||||
return err == nil && amount == order.ProviderAmountMinor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func v5PayOrderDataMatchesOrder(data V5PayNotifyData, order ledger.ExternalRechargeOrder) bool {
|
func v5PayOrderDataMatchesOrder(data V5PayNotifyData, order ledger.ExternalRechargeOrder) bool {
|
||||||
|
|||||||
@ -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 网关拒单的边界:本地订单要落失败便于排障,
|
// TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable 锁定 MiFaPay 网关拒单的边界:本地订单要落失败便于排障,
|
||||||
// 对外错误必须保持上游依赖失败语义,不能伪装成商品、幂等或状态冲突。
|
// 对外错误必须保持上游依赖失败语义,不能伪装成商品、幂等或状态冲突。
|
||||||
func TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable(t *testing.T) {
|
func TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable(t *testing.T) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user