314 lines
10 KiB
Go
314 lines
10 KiB
Go
package wallet
|
||
|
||
import (
|
||
"encoding/json"
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||
"math"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
func normalizeCreateExternalRechargeOrderCommand(command ledger.CreateExternalRechargeOrderCommand) ledger.CreateExternalRechargeOrderCommand {
|
||
command.AppCode = appcode.Normalize(command.AppCode)
|
||
command.CommandID = strings.TrimSpace(command.CommandID)
|
||
command.TargetCountryCode = strings.ToUpper(strings.TrimSpace(command.TargetCountryCode))
|
||
command.AudienceType = ledger.NormalizeRechargeAudienceType(command.AudienceType)
|
||
command.ProviderCode = ledger.NormalizePaymentProvider(command.ProviderCode)
|
||
command.ReturnURL = strings.TrimSpace(command.ReturnURL)
|
||
command.NotifyURL = strings.TrimSpace(command.NotifyURL)
|
||
command.ClientIP = strings.TrimSpace(command.ClientIP)
|
||
command.Language = normalizeMifaPayLanguage(command.Language)
|
||
command.PayerName = strings.TrimSpace(command.PayerName)
|
||
command.PayerAccount = strings.TrimSpace(command.PayerAccount)
|
||
command.PayerEmail = strings.TrimSpace(command.PayerEmail)
|
||
return command
|
||
}
|
||
|
||
func normalizeMifaPayLanguage(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return "en"
|
||
}
|
||
if comma := strings.IndexByte(value, ','); comma >= 0 {
|
||
value = value[:comma]
|
||
}
|
||
if semicolon := strings.IndexByte(value, ';'); semicolon >= 0 {
|
||
value = value[:semicolon]
|
||
}
|
||
value = strings.TrimSpace(value)
|
||
if !validMifaPayLanguageTag(value) {
|
||
return "en"
|
||
}
|
||
|
||
return value
|
||
}
|
||
|
||
func validMifaPayLanguageTag(value string) bool {
|
||
if value == "" {
|
||
return false
|
||
}
|
||
parts := strings.Split(value, "-")
|
||
if len(parts) == 0 || len(parts) > 4 || len(parts[0]) < 2 || len(parts[0]) > 3 || !allASCIILetters(parts[0]) {
|
||
return false
|
||
}
|
||
for _, part := range parts[1:] {
|
||
if len(part) < 2 || len(part) > 8 || !allASCIILettersOrDigits(part) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func allASCIILetters(value string) bool {
|
||
for _, char := range value {
|
||
if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') {
|
||
return false
|
||
}
|
||
}
|
||
return value != ""
|
||
}
|
||
|
||
func allASCIILettersOrDigits(value string) bool {
|
||
for _, char := range value {
|
||
if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') && (char < '0' || char > '9') {
|
||
return false
|
||
}
|
||
}
|
||
return value != ""
|
||
}
|
||
|
||
func validateCreateExternalRechargeOrderCommand(command ledger.CreateExternalRechargeOrderCommand) error {
|
||
if command.CommandID == "" || command.TargetUserID <= 0 || command.TargetRegionID <= 0 || command.ProductID <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "external recharge command is incomplete")
|
||
}
|
||
if command.AudienceType == "" || command.ProviderCode == "" {
|
||
return xerr.New(xerr.InvalidArgument, "external recharge command type is invalid")
|
||
}
|
||
if len(command.CommandID) > 128 {
|
||
return xerr.New(xerr.InvalidArgument, "command_id is too long")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateExternalRechargeProduct(product ledger.RechargeProduct, command ledger.CreateExternalRechargeOrderCommand) error {
|
||
if product.Status != ledger.RechargeProductStatusActive || !product.Enabled {
|
||
return xerr.New(xerr.Conflict, "recharge product is not active")
|
||
}
|
||
if product.Platform != ledger.RechargeProductPlatformWeb {
|
||
return xerr.New(xerr.InvalidArgument, "recharge product is not h5 product")
|
||
}
|
||
if product.AudienceType != command.AudienceType {
|
||
return xerr.New(xerr.Conflict, "recharge product audience does not match")
|
||
}
|
||
if !rechargeProductSupportsRegion(product, command.TargetRegionID) {
|
||
return xerr.New(xerr.Conflict, "recharge product is not available in user region")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateMifaPayMethodForOrder(method ledger.ThirdPartyPaymentMethod, command ledger.CreateExternalRechargeOrderCommand) error {
|
||
if method.ProviderCode != ledger.PaymentProviderMifaPay || method.Status != ledger.ThirdPartyPaymentStatusActive {
|
||
return xerr.New(xerr.Conflict, "mifapay method is not active")
|
||
}
|
||
if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) {
|
||
return xerr.New(xerr.Conflict, "mifapay method country does not match")
|
||
}
|
||
if _, err := calculateMifaPayProviderAmountMinor(100, method.USDToCurrencyRate, method.CurrencyCode); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateV5PayMethodForOrder(method ledger.ThirdPartyPaymentMethod, command ledger.CreateExternalRechargeOrderCommand) error {
|
||
if method.ProviderCode != ledger.PaymentProviderV5Pay || method.Status != ledger.ThirdPartyPaymentStatusActive {
|
||
return xerr.New(xerr.Conflict, "v5pay method is not active")
|
||
}
|
||
if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) {
|
||
return xerr.New(xerr.Conflict, "v5pay method country does not match")
|
||
}
|
||
if strings.TrimSpace(method.PayType) == "" {
|
||
return xerr.New(xerr.InvalidArgument, "v5pay product type is required")
|
||
}
|
||
if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate, method.CurrencyCode); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func calculateProviderAmountMinor(usdMinor int64, rate string, currencyCode string) (int64, error) {
|
||
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")
|
||
}
|
||
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
|
||
}
|
||
|
||
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":
|
||
return 0
|
||
default:
|
||
return 2
|
||
}
|
||
}
|
||
|
||
func amountMicroToUSDMinor(amountMicro int64) int64 {
|
||
if amountMicro <= 0 {
|
||
return 0
|
||
}
|
||
return amountMicro / 10_000
|
||
}
|
||
|
||
func validNonNegativeDecimal(value string) bool {
|
||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||
return err == nil && parsed >= 0
|
||
}
|
||
|
||
func mifaPayNotifyMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRechargeOrder) bool {
|
||
if !strings.EqualFold(data.Currency, order.CurrencyCode) {
|
||
return false
|
||
}
|
||
if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) {
|
||
return false
|
||
}
|
||
return strings.TrimSpace(data.Amount) == mifaPayProviderAmountValue(order.ProviderAmountMinor, order.CurrencyCode)
|
||
}
|
||
|
||
func mifaPayQueryMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRechargeOrder) bool {
|
||
if data.OrderID != "" && !strings.EqualFold(data.OrderID, order.OrderID) {
|
||
return false
|
||
}
|
||
if data.Currency != "" && !strings.EqualFold(data.Currency, order.CurrencyCode) {
|
||
return false
|
||
}
|
||
if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) {
|
||
return false
|
||
}
|
||
return strings.TrimSpace(data.Amount) == mifaPayProviderAmountValue(order.ProviderAmountMinor, order.CurrencyCode)
|
||
}
|
||
|
||
func v5PayOrderDataMatchesOrder(data V5PayNotifyData, order ledger.ExternalRechargeOrder) bool {
|
||
if data.OrderID != "" && !strings.EqualFold(data.OrderID, order.OrderID) {
|
||
return false
|
||
}
|
||
if data.Currency != "" && !strings.EqualFold(data.Currency, order.CurrencyCode) {
|
||
return false
|
||
}
|
||
if data.ProductType != "" && !strings.EqualFold(data.ProductType, order.PayType) {
|
||
return false
|
||
}
|
||
amount, err := parseProviderAmountMinor(data.Amount)
|
||
return err == nil && amount == order.ProviderAmountMinor
|
||
}
|
||
|
||
func parseProviderAmountMinor(value string) (int64, error) {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return 0, strconv.ErrSyntax
|
||
}
|
||
if !strings.Contains(value, ".") {
|
||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return parsed * 100, nil
|
||
}
|
||
parts := strings.SplitN(value, ".", 2)
|
||
whole, err := strconv.ParseInt(firstNonEmpty(parts[0], "0"), 10, 64)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
fraction := parts[1]
|
||
if len(fraction) > 2 {
|
||
fraction = fraction[:2]
|
||
}
|
||
for len(fraction) < 2 {
|
||
fraction += "0"
|
||
}
|
||
minor, err := strconv.ParseInt(fraction, 10, 64)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return whole*100 + minor, nil
|
||
}
|
||
|
||
func mifaPayProviderReturnURL(returnURL string) string {
|
||
returnURL = strings.TrimSpace(returnURL)
|
||
if returnURL == "" {
|
||
return returnURL
|
||
}
|
||
parsed, err := url.Parse(returnURL)
|
||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||
return returnURL
|
||
}
|
||
|
||
parsed.RawQuery = ""
|
||
parsed.Fragment = ""
|
||
return parsed.String()
|
||
}
|
||
|
||
func v5PayProviderReturnURL(returnURL string) string {
|
||
return mifaPayProviderReturnURL(returnURL)
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, value := range values {
|
||
if value = strings.TrimSpace(value); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func mustJSONText(value any) string {
|
||
body, err := json.Marshal(value)
|
||
if err != nil {
|
||
return "{}"
|
||
}
|
||
return string(body)
|
||
}
|