diff --git a/CHANGELOG.md b/CHANGELOG.md index f8d65551e..3b7f8ad3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Prowide Core - CHANGELOG +#### 10.3.20 - SNAPSHOT + * Feat: new `IsoUtils#getCountryName(code)` with the ISO 3166-1 English short name of a country, looked up by its alpha-2, alpha-3 or numeric code + * Feat: `IsoUtils#isUserAssignedCountryCode(code)` is now public, for the ISO 3166-1 user assigned range XA to XZ; it no longer accepts an upper case letter of another alphabet after the X, so `isValidISOCountry` rejects codes such as "XЖ" + #### 10.3.19 - August 2026 * (PW-3433) Fix: `OptionJPartyField.getValueByCodeword` no longer shifts the codeword/value pairs that follow a codeword with a blank value (for example "/CITY/" followed by "/USFW/021000018" returned "USFW" for CITY and null for USFW); a codeword present with a blank value now returns an empty string * (GH-341) Fix: `SwiftMessageUtils.money` revised for category 7: amount added for MT744, MT760, MT765 and MT786; changed to field 32B for MT750 and MT752, and to 34B for MT769; removed for MT707 diff --git a/src/main/java/com/prowidesoftware/swift/utils/IsoUtils.java b/src/main/java/com/prowidesoftware/swift/utils/IsoUtils.java index 387b28d42..caf79d597 100644 --- a/src/main/java/com/prowidesoftware/swift/utils/IsoUtils.java +++ b/src/main/java/com/prowidesoftware/swift/utils/IsoUtils.java @@ -15,7 +15,9 @@ */ package com.prowidesoftware.swift.utils; +import java.io.IOException; import java.util.*; +import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.Validate; @@ -27,6 +29,9 @@ * change or add new values. This can be particularly helpful when the application is not running on * the latest Java version and a currency change or addition has not yet been updated in the used JRE. * + *

The ISO 3166-1 country names are also available, by alpha-2, alpha-3 or numeric code, with + * {@link #getCountryName(String)}. + * * @since 7.9.2 */ public final class IsoUtils { @@ -96,8 +101,109 @@ public boolean isValidISOCountry(String countryCode) { return false; } - private boolean isUserAssignedCountryCode(String code) { - return code.charAt(0) == 'X' && Character.isUpperCase(code.charAt(1)); + /** + * Checks if the country code belongs to the ISO 3166-1 user assigned range XA to XZ, for example XK or XX. + * + *

These codes are accepted by {@link #isValidISOCountry(String)} regardless of the country codes list. The other + * ISO 3166-1 user assigned codes (AA, QM to QZ and ZZ) are not part of this range. + * + * @param countryCode a two letters capitalized country code, example: XK + * @return true if the parameter is an X followed by a letter in the A-Z range, false otherwise, including null + * @since 10.3.20 + */ + public boolean isUserAssignedCountryCode(String countryCode) { + return StringUtils.length(countryCode) == 2 + && countryCode.charAt(0) == 'X' + && countryCode.charAt(1) >= 'A' + && countryCode.charAt(1) <= 'Z'; + } + + /** + * Gets the English short name of a country from its ISO 3166-1 code. + * + *

The parameter can be the alpha-2 code (US), the alpha-3 code (USA) or the numeric code (840). The lookup is + * case-insensitive, ignores surrounding whitespace, accepts a numeric code with fewer or more leading zeros than + * the three digits of the ISO code (28 or 0028 for Antigua and Barbuda, whose ISO numeric code is 028), and does + * not depend on the default locale. + * + *

The names are the English short names of the officially assigned codes, in the comma-inverted form of the + * ISO 3166-1 lists (for example "Bolivia, Plurinational State of"). This catalog is independent of the country + * codes list: a code added with {@link #addCountry(String)} has no name, and the user assigned codes (the XA to + * XZ range) have no name either. + * + * @param code an alpha-2, alpha-3 or numeric ISO 3166-1 country code + * @return the English short name of the country, or null if the parameter is null, blank or not an officially + * assigned code + * @since 10.3.20 + */ + public String getCountryName(String code) { + if (code == null) { + return null; + } + String key = code.trim().toUpperCase(Locale.ROOT); + if (isAsciiDigits(key)) { + String digits = stripLeadingZeros(key); + if (digits.length() > 3) { + return null; + } + key = "000".substring(digits.length()) + digits; + } + return CountryNames.BY_CODE.get(key); + } + + private static boolean isAsciiDigits(String s) { + if (s.isEmpty()) { + return false; + } + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c < '0' || c > '9') { + return false; + } + } + return true; + } + + private static String stripLeadingZeros(String digits) { + int i = 0; + while (i < digits.length() && digits.charAt(i) == '0') { + i++; + } + return digits.substring(i); + } + + /** + * Lazily loaded ISO 3166-1 names, keyed by the alpha-2, alpha-3 and numeric codes. + * + *

The data comes from the IsoCountries.txt resource, one country per line as alpha-2;alpha-3;numeric;name, + * with lines starting with # ignored. + */ + private static final class CountryNames { + private static final Map BY_CODE = load(); + + private static Map load() { + Map map = new HashMap<>(); + try { + String content = Lib.readResource("IsoCountries.txt", "UTF-8", IsoUtils.class); + for (String line : content.split("\\R")) { + if (line.isEmpty() || line.charAt(0) == '#') { + continue; + } + String[] fields = line.split(";", 4); + if (fields.length == 4) { + map.put(fields[0], fields[3]); + map.put(fields[1], fields[3]); + map.put(fields[2], fields[3]); + } + } + } catch (IOException e) { + log.log(Level.SEVERE, "Could not load the ISO 3166-1 country names", e); + } + if (map.isEmpty()) { + log.severe("The ISO 3166-1 country names resource IsoCountries.txt is missing or empty"); + } + return map; + } } /** diff --git a/src/main/resources/IsoCountries.txt b/src/main/resources/IsoCountries.txt new file mode 100644 index 000000000..6bd49ecb0 --- /dev/null +++ b/src/main/resources/IsoCountries.txt @@ -0,0 +1,250 @@ +# ISO 3166-1 officially assigned countries: alpha-2;alpha-3;numeric;English short name +AD;AND;020;Andorra +AE;ARE;784;United Arab Emirates +AF;AFG;004;Afghanistan +AG;ATG;028;Antigua and Barbuda +AI;AIA;660;Anguilla +AL;ALB;008;Albania +AM;ARM;051;Armenia +AO;AGO;024;Angola +AQ;ATA;010;Antarctica +AR;ARG;032;Argentina +AS;ASM;016;American Samoa +AT;AUT;040;Austria +AU;AUS;036;Australia +AW;ABW;533;Aruba +AX;ALA;248;Åland Islands +AZ;AZE;031;Azerbaijan +BA;BIH;070;Bosnia and Herzegovina +BB;BRB;052;Barbados +BD;BGD;050;Bangladesh +BE;BEL;056;Belgium +BF;BFA;854;Burkina Faso +BG;BGR;100;Bulgaria +BH;BHR;048;Bahrain +BI;BDI;108;Burundi +BJ;BEN;204;Benin +BL;BLM;652;Saint Barthélemy +BM;BMU;060;Bermuda +BN;BRN;096;Brunei Darussalam +BO;BOL;068;Bolivia, Plurinational State of +BQ;BES;535;Bonaire, Sint Eustatius and Saba +BR;BRA;076;Brazil +BS;BHS;044;Bahamas +BT;BTN;064;Bhutan +BV;BVT;074;Bouvet Island +BW;BWA;072;Botswana +BY;BLR;112;Belarus +BZ;BLZ;084;Belize +CA;CAN;124;Canada +CC;CCK;166;Cocos (Keeling) Islands +CD;COD;180;Congo, Democratic Republic of the +CF;CAF;140;Central African Republic +CG;COG;178;Congo +CH;CHE;756;Switzerland +CI;CIV;384;Côte d'Ivoire +CK;COK;184;Cook Islands +CL;CHL;152;Chile +CM;CMR;120;Cameroon +CN;CHN;156;China +CO;COL;170;Colombia +CR;CRI;188;Costa Rica +CU;CUB;192;Cuba +CV;CPV;132;Cabo Verde +CW;CUW;531;Curaçao +CX;CXR;162;Christmas Island +CY;CYP;196;Cyprus +CZ;CZE;203;Czechia +DE;DEU;276;Germany +DJ;DJI;262;Djibouti +DK;DNK;208;Denmark +DM;DMA;212;Dominica +DO;DOM;214;Dominican Republic +DZ;DZA;012;Algeria +EC;ECU;218;Ecuador +EE;EST;233;Estonia +EG;EGY;818;Egypt +EH;ESH;732;Western Sahara +ER;ERI;232;Eritrea +ES;ESP;724;Spain +ET;ETH;231;Ethiopia +FI;FIN;246;Finland +FJ;FJI;242;Fiji +FK;FLK;238;Falkland Islands (Malvinas) +FM;FSM;583;Micronesia, Federated States of +FO;FRO;234;Faroe Islands +FR;FRA;250;France +GA;GAB;266;Gabon +GB;GBR;826;United Kingdom of Great Britain and Northern Ireland +GD;GRD;308;Grenada +GE;GEO;268;Georgia +GF;GUF;254;French Guiana +GG;GGY;831;Guernsey +GH;GHA;288;Ghana +GI;GIB;292;Gibraltar +GL;GRL;304;Greenland +GM;GMB;270;Gambia +GN;GIN;324;Guinea +GP;GLP;312;Guadeloupe +GQ;GNQ;226;Equatorial Guinea +GR;GRC;300;Greece +GS;SGS;239;South Georgia and the South Sandwich Islands +GT;GTM;320;Guatemala +GU;GUM;316;Guam +GW;GNB;624;Guinea-Bissau +GY;GUY;328;Guyana +HK;HKG;344;Hong Kong +HM;HMD;334;Heard Island and McDonald Islands +HN;HND;340;Honduras +HR;HRV;191;Croatia +HT;HTI;332;Haiti +HU;HUN;348;Hungary +ID;IDN;360;Indonesia +IE;IRL;372;Ireland +IL;ISR;376;Israel +IM;IMN;833;Isle of Man +IN;IND;356;India +IO;IOT;086;British Indian Ocean Territory +IQ;IRQ;368;Iraq +IR;IRN;364;Iran, Islamic Republic of +IS;ISL;352;Iceland +IT;ITA;380;Italy +JE;JEY;832;Jersey +JM;JAM;388;Jamaica +JO;JOR;400;Jordan +JP;JPN;392;Japan +KE;KEN;404;Kenya +KG;KGZ;417;Kyrgyzstan +KH;KHM;116;Cambodia +KI;KIR;296;Kiribati +KM;COM;174;Comoros +KN;KNA;659;Saint Kitts and Nevis +KP;PRK;408;Korea, Democratic People's Republic of +KR;KOR;410;Korea, Republic of +KW;KWT;414;Kuwait +KY;CYM;136;Cayman Islands +KZ;KAZ;398;Kazakhstan +LA;LAO;418;Lao People's Democratic Republic +LB;LBN;422;Lebanon +LC;LCA;662;Saint Lucia +LI;LIE;438;Liechtenstein +LK;LKA;144;Sri Lanka +LR;LBR;430;Liberia +LS;LSO;426;Lesotho +LT;LTU;440;Lithuania +LU;LUX;442;Luxembourg +LV;LVA;428;Latvia +LY;LBY;434;Libya +MA;MAR;504;Morocco +MC;MCO;492;Monaco +MD;MDA;498;Moldova, Republic of +ME;MNE;499;Montenegro +MF;MAF;663;Saint Martin (French part) +MG;MDG;450;Madagascar +MH;MHL;584;Marshall Islands +MK;MKD;807;North Macedonia +ML;MLI;466;Mali +MM;MMR;104;Myanmar +MN;MNG;496;Mongolia +MO;MAC;446;Macao +MP;MNP;580;Northern Mariana Islands +MQ;MTQ;474;Martinique +MR;MRT;478;Mauritania +MS;MSR;500;Montserrat +MT;MLT;470;Malta +MU;MUS;480;Mauritius +MV;MDV;462;Maldives +MW;MWI;454;Malawi +MX;MEX;484;Mexico +MY;MYS;458;Malaysia +MZ;MOZ;508;Mozambique +NA;NAM;516;Namibia +NC;NCL;540;New Caledonia +NE;NER;562;Niger +NF;NFK;574;Norfolk Island +NG;NGA;566;Nigeria +NI;NIC;558;Nicaragua +NL;NLD;528;Netherlands, Kingdom of the +NO;NOR;578;Norway +NP;NPL;524;Nepal +NR;NRU;520;Nauru +NU;NIU;570;Niue +NZ;NZL;554;New Zealand +OM;OMN;512;Oman +PA;PAN;591;Panama +PE;PER;604;Peru +PF;PYF;258;French Polynesia +PG;PNG;598;Papua New Guinea +PH;PHL;608;Philippines +PK;PAK;586;Pakistan +PL;POL;616;Poland +PM;SPM;666;Saint Pierre and Miquelon +PN;PCN;612;Pitcairn +PR;PRI;630;Puerto Rico +PS;PSE;275;Palestine, State of +PT;PRT;620;Portugal +PW;PLW;585;Palau +PY;PRY;600;Paraguay +QA;QAT;634;Qatar +RE;REU;638;Réunion +RO;ROU;642;Romania +RS;SRB;688;Serbia +RU;RUS;643;Russian Federation +RW;RWA;646;Rwanda +SA;SAU;682;Saudi Arabia +SB;SLB;090;Solomon Islands +SC;SYC;690;Seychelles +SD;SDN;729;Sudan +SE;SWE;752;Sweden +SG;SGP;702;Singapore +SH;SHN;654;Saint Helena, Ascension and Tristan da Cunha +SI;SVN;705;Slovenia +SJ;SJM;744;Svalbard and Jan Mayen +SK;SVK;703;Slovakia +SL;SLE;694;Sierra Leone +SM;SMR;674;San Marino +SN;SEN;686;Senegal +SO;SOM;706;Somalia +SR;SUR;740;Suriname +SS;SSD;728;South Sudan +ST;STP;678;Sao Tome and Principe +SV;SLV;222;El Salvador +SX;SXM;534;Sint Maarten (Dutch part) +SY;SYR;760;Syrian Arab Republic +SZ;SWZ;748;Eswatini +TC;TCA;796;Turks and Caicos Islands +TD;TCD;148;Chad +TF;ATF;260;French Southern Territories +TG;TGO;768;Togo +TH;THA;764;Thailand +TJ;TJK;762;Tajikistan +TK;TKL;772;Tokelau +TL;TLS;626;Timor-Leste +TM;TKM;795;Turkmenistan +TN;TUN;788;Tunisia +TO;TON;776;Tonga +TR;TUR;792;Türkiye +TT;TTO;780;Trinidad and Tobago +TV;TUV;798;Tuvalu +TW;TWN;158;Taiwan, Province of China +TZ;TZA;834;Tanzania, United Republic of +UA;UKR;804;Ukraine +UG;UGA;800;Uganda +UM;UMI;581;United States Minor Outlying Islands +US;USA;840;United States of America +UY;URY;858;Uruguay +UZ;UZB;860;Uzbekistan +VA;VAT;336;Holy See +VC;VCT;670;Saint Vincent and the Grenadines +VE;VEN;862;Venezuela, Bolivarian Republic of +VG;VGB;092;Virgin Islands (British) +VI;VIR;850;Virgin Islands (U.S.) +VN;VNM;704;Viet Nam +VU;VUT;548;Vanuatu +WF;WLF;876;Wallis and Futuna +WS;WSM;882;Samoa +YE;YEM;887;Yemen +YT;MYT;175;Mayotte +ZA;ZAF;710;South Africa +ZM;ZMB;894;Zambia +ZW;ZWE;716;Zimbabwe diff --git a/src/test/java/com/prowidesoftware/swift/utils/IsoUtilsTest.java b/src/test/java/com/prowidesoftware/swift/utils/IsoUtilsTest.java index e7cfea1a6..8c886549e 100644 --- a/src/test/java/com/prowidesoftware/swift/utils/IsoUtilsTest.java +++ b/src/test/java/com/prowidesoftware/swift/utils/IsoUtilsTest.java @@ -15,9 +15,9 @@ */ package com.prowidesoftware.swift.utils; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; +import java.util.Locale; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -99,6 +99,32 @@ public void testCountriesUserAssigned() { assertTrue(IsoUtils.getInstance().isValidISOCountry("XZ")); } + @Test + public void testIsUserAssignedCountryCode() { + for (char c = 'A'; c <= 'Z'; c++) { + assertTrue(IsoUtils.getInstance().isUserAssignedCountryCode("X" + c), "X" + c); + } + assertFalse(IsoUtils.getInstance().isUserAssignedCountryCode(null)); + assertFalse(IsoUtils.getInstance().isUserAssignedCountryCode("")); + assertFalse(IsoUtils.getInstance().isUserAssignedCountryCode("X")); + assertFalse(IsoUtils.getInstance().isUserAssignedCountryCode("XXX")); + assertFalse(IsoUtils.getInstance().isUserAssignedCountryCode("xx")); + assertFalse(IsoUtils.getInstance().isUserAssignedCountryCode("Xx")); + assertFalse(IsoUtils.getInstance().isUserAssignedCountryCode("X1")); + assertFalse(IsoUtils.getInstance().isUserAssignedCountryCode("FR")); + assertFalse(IsoUtils.getInstance().isUserAssignedCountryCode("AA")); + assertFalse(IsoUtils.getInstance().isUserAssignedCountryCode("QM")); + assertFalse(IsoUtils.getInstance().isUserAssignedCountryCode("ZZ")); + } + + /** Only the A-Z range follows the X, an upper case letter of another alphabet is not a user assigned code */ + @Test + public void testUserAssignedCountryCodeIsAsciiOnly() { + assertFalse(IsoUtils.getInstance().isUserAssignedCountryCode("XЖ")); + assertFalse(IsoUtils.getInstance().isValidISOCountry("XЖ")); + assertFalse(IsoUtils.getInstance().isValidISOCountry("XÉ")); + } + @Test public void testAddCountry_1() { Assertions.assertThrows( @@ -162,4 +188,111 @@ public void testAddCurrency_5() { IsoUtils.getInstance().removeCurrency("DSZ"); assertFalse(IsoUtils.getInstance().isValidISOCurrency("DSZ")); } + + @Test + public void testCountryNameByAlpha2() { + assertEquals("United States of America", IsoUtils.getInstance().getCountryName("US")); + assertEquals("Spain", IsoUtils.getInstance().getCountryName("ES")); + assertEquals("Antigua and Barbuda", IsoUtils.getInstance().getCountryName("AG")); + } + + @Test + public void testCountryNameByAlpha3() { + assertEquals("Spain", IsoUtils.getInstance().getCountryName("ESP")); + assertEquals("Antigua and Barbuda", IsoUtils.getInstance().getCountryName("ATG")); + assertEquals("Sudan", IsoUtils.getInstance().getCountryName("SDN")); + } + + @Test + public void testCountryNameByNumeric() { + assertEquals("Antigua and Barbuda", IsoUtils.getInstance().getCountryName("028")); + assertEquals("Solomon Islands", IsoUtils.getInstance().getCountryName("090")); + assertEquals("Uruguay", IsoUtils.getInstance().getCountryName("858")); + assertEquals("United States of America", IsoUtils.getInstance().getCountryName("840")); + } + + /** A numeric code with fewer or more leading zeros than the three ISO digits still identifies the country */ + @Test + public void testCountryNameByNumericWithOtherLeadingZeros() { + assertEquals("Antigua and Barbuda", IsoUtils.getInstance().getCountryName("28")); + assertEquals("Afghanistan", IsoUtils.getInstance().getCountryName("4")); + assertEquals("Antigua and Barbuda", IsoUtils.getInstance().getCountryName("0028")); + assertEquals("United States of America", IsoUtils.getInstance().getCountryName("000840")); + assertNull(IsoUtils.getInstance().getCountryName("0000")); + assertNull(IsoUtils.getInstance().getCountryName("1234")); + } + + @Test + public void testCountryNameIsLenientWithCaseAndSurroundingSpace() { + assertEquals("Uruguay", IsoUtils.getInstance().getCountryName("uy")); + assertEquals("Sudan", IsoUtils.getInstance().getCountryName("Sdn")); + assertEquals("Spain", IsoUtils.getInstance().getCountryName(" ES ")); + } + + /** The numeric lookup must not depend on the digits of the default locale */ + @Test + public void testCountryNameByNumericIsIndependentOfTheDefaultLocale() { + Locale previous = Locale.getDefault(); + Locale.setDefault(new Locale.Builder().setLanguage("ar").setRegion("EG").build()); + try { + assertEquals("Antigua and Barbuda", IsoUtils.getInstance().getCountryName("28")); + assertEquals("Afghanistan", IsoUtils.getInstance().getCountryName("004")); + } finally { + Locale.setDefault(previous); + } + } + + @Test + public void testCountryNameUnknownIsNull() { + assertNull(IsoUtils.getInstance().getCountryName(null)); + assertNull(IsoUtils.getInstance().getCountryName("")); + assertNull(IsoUtils.getInstance().getCountryName(" ")); + assertNull(IsoUtils.getInstance().getCountryName("UK")); + assertNull(IsoUtils.getInstance().getCountryName("foo")); + assertNull(IsoUtils.getInstance().getCountryName("999")); + assertNull(IsoUtils.getInstance().getCountryName("99999999999999999999999")); + assertNull(IsoUtils.getInstance().getCountryName("USAX")); + } + + /** The user assigned codes have no name, even when added to the country codes */ + @Test + public void testCountryNameOfUserAssignedCodes() { + for (String code : new String[] {"XA", "XK", "XX", "XZ", "QM", "QZ", "AA", "ZZ"}) { + assertNull(IsoUtils.getInstance().getCountryName(code), code); + } + IsoUtils.getInstance().addCountry("XK"); + try { + assertNull(IsoUtils.getInstance().getCountryName("XK")); + } finally { + IsoUtils.getInstance().removeCountry("XK"); + } + } + + /** The ISO short names, free of footnote marks, with the non-ASCII names read as UTF-8 */ + @Test + public void testCountryNamesAreTheIsoShortNames() { + assertEquals("Afghanistan", IsoUtils.getInstance().getCountryName("AF")); + assertEquals("China", IsoUtils.getInstance().getCountryName("CN")); + assertEquals("Taiwan, Province of China", IsoUtils.getInstance().getCountryName("TW")); + assertEquals("Bonaire, Sint Eustatius and Saba", IsoUtils.getInstance().getCountryName("BQ")); + assertEquals("Svalbard and Jan Mayen", IsoUtils.getInstance().getCountryName("SJ")); + assertEquals("Bolivia, Plurinational State of", IsoUtils.getInstance().getCountryName("BO")); + assertEquals("Korea, Republic of", IsoUtils.getInstance().getCountryName("KR")); + assertEquals("Türkiye", IsoUtils.getInstance().getCountryName("TR")); + assertEquals("Côte d'Ivoire", IsoUtils.getInstance().getCountryName("CI")); + assertEquals("Åland Islands", IsoUtils.getInstance().getCountryName("AX")); + } + + /** Every officially assigned country known to the JDK has a name, by any of its three codes */ + @Test + public void testEveryJdkCountryHasAName() { + for (String alpha2 : Locale.getISOCountries()) { + String name = IsoUtils.getInstance().getCountryName(alpha2); + assertNotNull(name, alpha2); + assertFalse(name.isEmpty(), alpha2); + assertFalse(name.contains("["), alpha2 + " carries a footnote: " + name); + String alpha3 = new Locale.Builder().setRegion(alpha2).build().getISO3Country(); + assertEquals(name, IsoUtils.getInstance().getCountryName(alpha3), alpha2 + " by alpha-3 " + alpha3); + } + } }