Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions iso/country/numeric.go
Original file line number Diff line number Diff line change
@@ -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
}
93 changes: 93 additions & 0 deletions iso/country/numeric_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading