From f184cdc8748c3f0909c421fae70d5a491dbfed51 Mon Sep 17 00:00:00 2001 From: lei-wego Date: Fri, 22 May 2026 06:03:52 +0000 Subject: [PATCH] =?UTF-8?q?feat(iso/country):=20add=20ISO=203166-1=20numer?= =?UTF-8?q?ic=20=E2=86=94=20alpha-2=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some upstream providers (Juspay /cardbins) report country as ISO 3166-1 numeric code rather than alpha-2. Adds FromNumeric / Numeric helpers to the iso/country package, sharing the single alpha-2 constant table already defined there. Input normalisation accepts 1-3 digit input with or without leading zeros (Juspay returns "76" for Brazil, not "076"). Needed by PAY-2143 (Juspay BIN provider in payments service). --- iso/country/numeric.go | 113 ++++++++++++++++++++++++++++++++++++ iso/country/numeric_test.go | 93 +++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 iso/country/numeric.go create mode 100644 iso/country/numeric_test.go diff --git a/iso/country/numeric.go b/iso/country/numeric.go new file mode 100644 index 0000000..cf2adc9 --- /dev/null +++ b/iso/country/numeric.go @@ -0,0 +1,113 @@ +package country + +import "strings" + +// ISO 3166-1 numeric ↔ alpha-2 lookup. +// +// Some upstream providers (e.g. Juspay /cardbins) report country as the +// ISO 3166-1 numeric code (e.g. "356" for India, "76" for Brazil — sometimes +// with and sometimes without the leading zero). This package's canonical +// representation is alpha-2, so we keep a single table and convert at the +// boundary. +// +// Keys are 3-digit zero-padded strings; FromNumeric normalises its input +// before lookup so callers can pass "76", "076", or " 76 " interchangeably. + +var numericToAlpha2 = map[string]string{ + "004": AF, "008": AL, "010": AQ, "012": DZ, "016": AS, + "020": AD, "024": AO, "028": AG, "031": AZ, "032": AR, + "036": AU, "040": AT, "044": BS, "048": BH, "050": BD, + "051": AM, "052": BB, "056": BE, "060": BM, "064": BT, + "068": BO, "070": BA, "072": BW, "074": BV, "076": BR, + "084": BZ, "086": IO, "090": SB, "092": VG, "096": BN, + "100": BG, "104": MM, "108": BI, "112": BY, "116": KH, + "120": CM, "124": CA, "132": CV, "136": KY, "140": CF, + "144": LK, "148": TD, "152": CL, "156": CN, "158": TW, + "162": CX, "166": CC, "170": CO, "174": KM, "175": YT, + "178": CG, "180": CD, "184": CK, "188": CR, "191": HR, + "192": CU, "196": CY, "203": CZ, "204": BJ, "208": DK, + "212": DM, "214": DO, "218": EC, "222": SV, "226": GQ, + "231": ET, "232": ER, "233": EE, "234": FO, "238": FK, + "239": GS, "242": FJ, "246": FI, "248": AX, "250": FR, + "254": GF, "258": PF, "260": TF, "262": DJ, "266": GA, + "268": GE, "270": GM, "275": PS, "276": DE, "288": GH, + "292": GI, "296": KI, "300": GR, "304": GL, "308": GD, + "312": GP, "316": GU, "320": GT, "324": GN, "328": GY, + "332": HT, "334": HM, "336": VA, "340": HN, "344": HK, + "348": HU, "352": IS, "356": IN, "360": ID, "364": IR, + "368": IQ, "372": IE, "376": IL, "380": IT, "384": CI, + "388": JM, "392": JP, "398": KZ, "400": JO, "404": KE, + "408": KP, "410": KR, "414": KW, "417": KG, "418": LA, + "422": LB, "426": LS, "428": LV, "430": LR, "434": LY, + "438": LI, "440": LT, "442": LU, "446": MO, "450": MG, + "454": MW, "458": MY, "462": MV, "466": ML, "470": MT, + "474": MQ, "478": MR, "480": MU, "484": MX, "492": MC, + "496": MN, "498": MD, "499": ME, "500": MS, "504": MA, + "508": MZ, "512": OM, "516": NA, "520": NR, "524": NP, + "528": NL, "531": CW, "533": AW, "534": SX, "535": BQ, + "540": NC, "548": VU, "554": NZ, "558": NI, "562": NE, + "566": NG, "570": NU, "574": NF, "578": NO, "580": MP, + "581": UM, "583": FM, "584": MH, "585": PW, "586": PK, + "591": PA, "598": PG, "600": PY, "604": PE, "608": PH, + "612": PN, "616": PL, "620": PT, "624": GW, "626": TL, + "630": PR, "634": QA, "638": RE, "642": RO, "643": RU, + "646": RW, "652": BL, "654": SH, "659": KN, "660": AI, + "662": LC, "663": MF, "666": PM, "670": VC, "674": SM, + "678": ST, "682": SA, "686": SN, "688": RS, "690": SC, + "694": SL, "702": SG, "703": SK, "704": VN, "705": SI, + "706": SO, "710": ZA, "716": ZW, "724": ES, "728": SS, + "729": SD, "732": EH, "740": SR, "744": SJ, "748": SZ, + "752": SE, "756": CH, "760": SY, "762": TJ, "764": TH, + "768": TG, "772": TK, "776": TO, "780": TT, "784": AE, + "788": TN, "792": TR, "795": TM, "796": TC, "798": TV, + "800": UG, "804": UA, "807": MK, "818": EG, "826": GB, + "831": GG, "832": JE, "833": IM, "834": TZ, "840": US, + "850": VI, "854": BF, "858": UY, "860": UZ, "862": VE, + "876": WF, "882": WS, "887": YE, "894": ZM, +} + +// alpha2ToNumeric is the inverse table, populated at init time so the source +// of truth stays a single map above. +var alpha2ToNumeric = func() map[string]string { + out := make(map[string]string, len(numericToAlpha2)) + for n, a2 := range numericToAlpha2 { + out[a2] = n + } + return out +}() + +// FromNumeric returns the ISO 3166-1 alpha-2 code for a given ISO 3166-1 +// numeric code. The input may be 1, 2 or 3 digits with or without leading +// zeros (e.g. "76", "076" both resolve to BR). Surrounding whitespace is +// ignored. Found is false for any unrecognised value. +func FromNumeric(numeric string) (alpha2 string, found bool) { + key := normaliseNumeric(numeric) + if key == "" { + return "", false + } + alpha2, found = numericToAlpha2[key] + return +} + +// Numeric returns the ISO 3166-1 numeric code (3-digit, zero-padded) for a +// given alpha-2 code, and a boolean indicating whether it was found. +func Numeric(alpha2 string) (numeric string, found bool) { + numeric, found = alpha2ToNumeric[strings.ToUpper(strings.TrimSpace(alpha2))] + return +} + +func normaliseNumeric(in string) string { + s := strings.TrimSpace(in) + if s == "" || len(s) > 3 { + return "" + } + for _, r := range s { + if r < '0' || r > '9' { + return "" + } + } + for len(s) < 3 { + s = "0" + s + } + return s +} diff --git a/iso/country/numeric_test.go b/iso/country/numeric_test.go new file mode 100644 index 0000000..c4fb561 --- /dev/null +++ b/iso/country/numeric_test.go @@ -0,0 +1,93 @@ +package country + +import "testing" + +func TestFromNumeric(t *testing.T) { + tests := []struct { + name string + given string + want string + wantOk bool + }{ + {name: "unknown numeric", given: "000", want: "", wantOk: false}, + {name: "empty", given: "", want: "", wantOk: false}, + {name: "whitespace only", given: " ", want: "", wantOk: false}, + {name: "non-numeric input", given: "12a", want: "", wantOk: false}, + {name: "too long", given: "1234", want: "", wantOk: false}, + + {name: "single-digit Brazil", given: "76", want: BR, wantOk: true}, + {name: "padded Brazil", given: "076", want: BR, wantOk: true}, + {name: "padded surrounded by whitespace", given: " 076 ", want: BR, wantOk: true}, + + {name: "India", given: "356", want: IN, wantOk: true}, + {name: "United States", given: "840", want: US, wantOk: true}, + {name: "United Kingdom", given: "826", want: GB, wantOk: true}, + {name: "UAE", given: "784", want: AE, wantOk: true}, + {name: "Saudi Arabia", given: "682", want: SA, wantOk: true}, + {name: "Egypt", given: "818", want: EG, wantOk: true}, + {name: "Singapore", given: "702", want: SG, wantOk: true}, + {name: "Indonesia", given: "360", want: ID, wantOk: true}, + {name: "China", given: "156", want: CN, wantOk: true}, + {name: "Japan", given: "392", want: JP, wantOk: true}, + {name: "Germany", given: "276", want: DE, wantOk: true}, + {name: "France", given: "250", want: FR, wantOk: true}, + {name: "Hong Kong", given: "344", want: HK, wantOk: true}, + + {name: "Austria (40 → 040)", given: "40", want: AT, wantOk: true}, + {name: "Australia (36 → 036)", given: "36", want: AU, wantOk: true}, + {name: "Argentina (32 → 032)", given: "32", want: AR, wantOk: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := FromNumeric(tt.given) + if got != tt.want || ok != tt.wantOk { + t.Errorf("FromNumeric(%q) = (%q, %v), want (%q, %v)", + tt.given, got, ok, tt.want, tt.wantOk) + } + }) + } +} + +func TestNumeric(t *testing.T) { + tests := []struct { + name string + given string + want string + wantOk bool + }{ + {name: "unknown alpha-2", given: "ZZ", want: "", wantOk: false}, + {name: "empty", given: "", want: "", wantOk: false}, + + {name: "United States", given: US, want: "840", wantOk: true}, + {name: "Brazil zero-pads", given: BR, want: "076", wantOk: true}, + {name: "India", given: IN, want: "356", wantOk: true}, + {name: "lowercase normalised", given: "us", want: "840", wantOk: true}, + {name: "whitespace stripped", given: " GB ", want: "826", wantOk: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := Numeric(tt.given) + if got != tt.want || ok != tt.wantOk { + t.Errorf("Numeric(%q) = (%q, %v), want (%q, %v)", + tt.given, got, ok, tt.want, tt.wantOk) + } + }) + } +} + +func TestNumericRoundTrip(t *testing.T) { + for numeric, a2 := range numericToAlpha2 { + got, ok := FromNumeric(numeric) + if !ok || got != a2 { + t.Errorf("FromNumeric(%q) = (%q, %v); want (%q, true)", + numeric, got, ok, a2) + } + back, ok := Numeric(a2) + if !ok || back != numeric { + t.Errorf("Numeric(%q) = (%q, %v); want (%q, true)", + a2, back, ok, numeric) + } + } +}