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 := calculateProviderAmountMinor(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 } 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 } amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64) return err == nil && amount == order.ProviderAmountMinor } 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 } amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64) return err == nil && amount == order.ProviderAmountMinor } 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) }